Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
enum SingboxFieldKind {
|
||||
text,
|
||||
secret,
|
||||
integer,
|
||||
integerList,
|
||||
port,
|
||||
boolean,
|
||||
stringList,
|
||||
}
|
||||
|
||||
class SingboxProxyFormField {
|
||||
final String key;
|
||||
final String label;
|
||||
final String? helperText;
|
||||
final String? defaultValue;
|
||||
final bool required;
|
||||
final SingboxFieldKind kind;
|
||||
final int? exactListLength;
|
||||
final int? minValue;
|
||||
final int? maxValue;
|
||||
|
||||
const SingboxProxyFormField({
|
||||
required this.key,
|
||||
required this.label,
|
||||
this.helperText,
|
||||
this.defaultValue,
|
||||
this.required = false,
|
||||
this.kind = SingboxFieldKind.text,
|
||||
this.exactListLength,
|
||||
this.minValue,
|
||||
this.maxValue,
|
||||
});
|
||||
|
||||
bool get isSecret => kind == SingboxFieldKind.secret;
|
||||
bool get isNumber =>
|
||||
kind == SingboxFieldKind.integer || kind == SingboxFieldKind.port;
|
||||
bool get isIntegerList => kind == SingboxFieldKind.integerList;
|
||||
bool get isPort => kind == SingboxFieldKind.port;
|
||||
bool get isBoolean => kind == SingboxFieldKind.boolean;
|
||||
bool get isStringList => kind == SingboxFieldKind.stringList;
|
||||
}
|
||||
|
||||
/// Shared parsing of boolean form values. Returns null when the text doesn't
|
||||
/// look like a boolean (treated as "unset"), so callers can distinguish absent
|
||||
/// from explicitly-false.
|
||||
bool? parseFormBool(String value) {
|
||||
return switch (value.trim().toLowerCase()) {
|
||||
'true' || 'yes' || '1' || 'on' => true,
|
||||
'false' || 'no' || '0' || 'off' => false,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Splits a multi-value form input on newlines or commas, trims, drops blanks.
|
||||
List<String> splitFormStringList(String value) {
|
||||
return value
|
||||
.split(RegExp(r'[\n,]'))
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
|
||||
|
||||
class SingboxProxyFormSpec {
|
||||
final SingboxProxyProfileType type;
|
||||
final String outboundType;
|
||||
final List<SingboxProxyFormField> fields;
|
||||
|
||||
const SingboxProxyFormSpec({
|
||||
required this.type,
|
||||
required this.outboundType,
|
||||
required this.fields,
|
||||
});
|
||||
|
||||
String? validate(Map<String, String> values) {
|
||||
for (final field in fields) {
|
||||
final value = values[field.key]?.trim() ?? '';
|
||||
if (field.required && value.isEmpty) {
|
||||
return '${field.label} is required.';
|
||||
}
|
||||
if (field.isNumber && value.isNotEmpty) {
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed == null || parsed < 1) {
|
||||
return '${field.label} must be a positive number.';
|
||||
}
|
||||
if (field.isPort && parsed > 65535) {
|
||||
return '${field.label} must be between 1 and 65535.';
|
||||
}
|
||||
}
|
||||
if (field.isIntegerList && value.isNotEmpty) {
|
||||
final items = splitFormStringList(value);
|
||||
final exactListLength = field.exactListLength;
|
||||
if (exactListLength != null && items.length != exactListLength) {
|
||||
return '${field.label} must contain $exactListLength numbers.';
|
||||
}
|
||||
for (final item in items) {
|
||||
final parsed = int.tryParse(item);
|
||||
if (parsed == null) {
|
||||
return '${field.label} must contain only numbers.';
|
||||
}
|
||||
final minValue = field.minValue;
|
||||
if (minValue != null && parsed < minValue) {
|
||||
return '${field.label} must contain numbers greater than or equal to $minValue.';
|
||||
}
|
||||
final maxValue = field.maxValue;
|
||||
if (maxValue != null && parsed > maxValue) {
|
||||
return '${field.label} must contain numbers less than or equal to $maxValue.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (field.isBoolean && value.isNotEmpty && parseFormBool(value) == null) {
|
||||
return '${field.label} must be true or false.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String toConfigJson(Map<String, String> values) {
|
||||
final config = <String, dynamic>{'type': outboundType};
|
||||
for (final field in fields.where((field) => !field.isSecret)) {
|
||||
final value = values[field.key]?.trim() ?? '';
|
||||
if (value.isEmpty) continue;
|
||||
_setJsonValue(config, field.key, _fieldJsonValue(field, value));
|
||||
}
|
||||
|
||||
return const JsonEncoder.withIndent(' ').convert(config);
|
||||
}
|
||||
|
||||
String? toSecretJson(Map<String, String> values) {
|
||||
final secrets = <String, dynamic>{};
|
||||
for (final field in fields.where((field) => field.isSecret)) {
|
||||
final value = values[field.key]?.trim() ?? '';
|
||||
if (value.isEmpty) continue;
|
||||
_setJsonValue(secrets, field.key, _fieldJsonValue(field, value));
|
||||
}
|
||||
|
||||
if (secrets.isEmpty) return null;
|
||||
return const JsonEncoder.withIndent(' ').convert(secrets);
|
||||
}
|
||||
|
||||
Map<String, String> valuesFromJson({
|
||||
required String configJson,
|
||||
String? secretJson,
|
||||
}) {
|
||||
final config = _decodeJsonObject(configJson) ?? const <String, dynamic>{};
|
||||
final secrets = secretJson == null
|
||||
? const <String, dynamic>{}
|
||||
: _decodeJsonObject(secretJson) ?? const <String, dynamic>{};
|
||||
final merged = {...config, ...secrets};
|
||||
|
||||
return {
|
||||
for (final field in fields)
|
||||
field.key: _fieldTextValue(
|
||||
_jsonValueAt(merged, field.key),
|
||||
field.defaultValue ?? '',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _decodeJsonObject(String rawJson) {
|
||||
try {
|
||||
final decoded = jsonDecode(rawJson) as Object?;
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Object _fieldJsonValue(SingboxProxyFormField field, String value) {
|
||||
if (field.isNumber) return int.parse(value);
|
||||
if (field.isIntegerList) {
|
||||
return splitFormStringList(value).map(int.parse).toList();
|
||||
}
|
||||
if (field.isBoolean) return parseFormBool(value)!;
|
||||
if (field.isStringList) return splitFormStringList(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
String _fieldTextValue(Object? value, String fallback) {
|
||||
if (value == null) return fallback;
|
||||
if (value is List) return value.map((item) => item.toString()).join('\n');
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
void _setJsonValue(Map<String, dynamic> target, String path, Object value) {
|
||||
final segments = path.split('.');
|
||||
var current = target;
|
||||
for (final segment in segments.take(segments.length - 1)) {
|
||||
current =
|
||||
current.putIfAbsent(segment, () => <String, dynamic>{})
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
current[segments.last] = value;
|
||||
}
|
||||
|
||||
Object? _jsonValueAt(Map<String, dynamic> source, String path) {
|
||||
Object? current = source;
|
||||
for (final segment in path.split('.')) {
|
||||
if (current is! Map<String, dynamic>) return null;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
|
||||
|
||||
const _serverField = SingboxProxyFormField(
|
||||
key: 'server',
|
||||
label: 'Server Address',
|
||||
required: true,
|
||||
);
|
||||
const _serverPortField = SingboxProxyFormField(
|
||||
key: 'server_port',
|
||||
label: 'Server Port',
|
||||
required: true,
|
||||
kind: SingboxFieldKind.port,
|
||||
);
|
||||
const _usernameField = SingboxProxyFormField(
|
||||
key: 'username',
|
||||
label: 'Username',
|
||||
kind: SingboxFieldKind.secret,
|
||||
);
|
||||
const _passwordField = SingboxProxyFormField(
|
||||
key: 'password',
|
||||
label: 'Password',
|
||||
required: true,
|
||||
kind: SingboxFieldKind.secret,
|
||||
);
|
||||
const _optionalPasswordField = SingboxProxyFormField(
|
||||
key: 'password',
|
||||
label: 'Password',
|
||||
kind: SingboxFieldKind.secret,
|
||||
);
|
||||
const _uuidField = SingboxProxyFormField(
|
||||
key: 'uuid',
|
||||
label: 'UUID',
|
||||
required: true,
|
||||
kind: SingboxFieldKind.secret,
|
||||
);
|
||||
const _tlsFields = [
|
||||
SingboxProxyFormField(
|
||||
key: 'tls.enabled',
|
||||
label: 'TLS Enabled',
|
||||
helperText: 'true or false.',
|
||||
kind: SingboxFieldKind.boolean,
|
||||
),
|
||||
SingboxProxyFormField(key: 'tls.server_name', label: 'TLS Server Name'),
|
||||
SingboxProxyFormField(
|
||||
key: 'tls.insecure',
|
||||
label: 'Allow Invalid TLS Certificates',
|
||||
helperText: 'true or false.',
|
||||
kind: SingboxFieldKind.boolean,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'tls.alpn',
|
||||
label: 'TLS ALPN',
|
||||
helperText: 'Comma-separated or one value per line.',
|
||||
kind: SingboxFieldKind.stringList,
|
||||
),
|
||||
];
|
||||
const _transportFields = [
|
||||
SingboxProxyFormField(
|
||||
key: 'transport.type',
|
||||
label: 'Transport Type',
|
||||
helperText: 'For example ws, http, grpc, or quic.',
|
||||
),
|
||||
SingboxProxyFormField(key: 'transport.path', label: 'Transport Path'),
|
||||
SingboxProxyFormField(
|
||||
key: 'transport.service_name',
|
||||
label: 'gRPC Service Name',
|
||||
),
|
||||
];
|
||||
const _multiplexFields = [
|
||||
SingboxProxyFormField(
|
||||
key: 'multiplex.enabled',
|
||||
label: 'Multiplex Enabled',
|
||||
helperText: 'true or false.',
|
||||
kind: SingboxFieldKind.boolean,
|
||||
),
|
||||
SingboxProxyFormField(key: 'multiplex.protocol', label: 'Multiplex Protocol'),
|
||||
SingboxProxyFormField(
|
||||
key: 'multiplex.max_connections',
|
||||
label: 'Multiplex Max Connections',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
];
|
||||
const _dialFields = [
|
||||
SingboxProxyFormField(key: 'detour', label: 'Dial Detour'),
|
||||
SingboxProxyFormField(key: 'bind_interface', label: 'Bind Interface'),
|
||||
SingboxProxyFormField(
|
||||
key: 'routing_mark',
|
||||
label: 'Routing Mark',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'domain_strategy',
|
||||
label: 'Domain Strategy',
|
||||
helperText: 'For example prefer_ipv4 or prefer_ipv6.',
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'connect_timeout',
|
||||
label: 'Connect Timeout',
|
||||
helperText: 'For example 5s.',
|
||||
),
|
||||
];
|
||||
const _v2rayAdvancedFields = [
|
||||
..._tlsFields,
|
||||
..._transportFields,
|
||||
..._multiplexFields,
|
||||
..._dialFields,
|
||||
];
|
||||
const _commonAdvancedFields = [..._multiplexFields, ..._dialFields];
|
||||
|
||||
const singboxProxyFormSpecs = <SingboxProxyProfileType, SingboxProxyFormSpec>{
|
||||
SingboxProxyProfileType.socks: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.socks,
|
||||
outboundType: 'socks',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
SingboxProxyFormField(
|
||||
key: 'version',
|
||||
label: 'SOCKS Version',
|
||||
defaultValue: '5',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
_usernameField,
|
||||
_optionalPasswordField,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.http: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.http,
|
||||
outboundType: 'http',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_usernameField,
|
||||
_optionalPasswordField,
|
||||
..._tlsFields,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.shadowsocks: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.shadowsocks,
|
||||
outboundType: 'shadowsocks',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
SingboxProxyFormField(
|
||||
key: 'method',
|
||||
label: 'Method',
|
||||
defaultValue: '2022-blake3-aes-128-gcm',
|
||||
required: true,
|
||||
),
|
||||
_passwordField,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.vmess: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.vmess,
|
||||
outboundType: 'vmess',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_uuidField,
|
||||
SingboxProxyFormField(
|
||||
key: 'security',
|
||||
label: 'Security',
|
||||
defaultValue: 'auto',
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'alter_id',
|
||||
label: 'Alter ID',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
..._v2rayAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.vless: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.vless,
|
||||
outboundType: 'vless',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_uuidField,
|
||||
SingboxProxyFormField(key: 'flow', label: 'Flow'),
|
||||
..._v2rayAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.trojan: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.trojan,
|
||||
outboundType: 'trojan',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_passwordField,
|
||||
..._v2rayAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.naive: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.naive,
|
||||
outboundType: 'naive',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_usernameField,
|
||||
_passwordField,
|
||||
..._tlsFields,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.hysteria: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.hysteria,
|
||||
outboundType: 'hysteria',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
SingboxProxyFormField(
|
||||
key: 'auth_str',
|
||||
label: 'Auth String',
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
SingboxProxyFormField(key: 'up', label: 'Upload Bandwidth'),
|
||||
SingboxProxyFormField(key: 'down', label: 'Download Bandwidth'),
|
||||
SingboxProxyFormField(
|
||||
key: 'obfs',
|
||||
label: 'Obfuscation',
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'recv_window_conn',
|
||||
label: 'Receive Window Conn',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'recv_window',
|
||||
label: 'Receive Window',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'disable_mtu_discovery',
|
||||
label: 'Disable MTU Discovery',
|
||||
helperText: 'true or false.',
|
||||
kind: SingboxFieldKind.boolean,
|
||||
),
|
||||
..._tlsFields,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.hysteria2: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.hysteria2,
|
||||
outboundType: 'hysteria2',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_passwordField,
|
||||
SingboxProxyFormField(
|
||||
key: 'up_mbps',
|
||||
label: 'Upload Mbps',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'down_mbps',
|
||||
label: 'Download Mbps',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
SingboxProxyFormField(key: 'obfs.type', label: 'Obfuscation Type'),
|
||||
SingboxProxyFormField(
|
||||
key: 'obfs.password',
|
||||
label: 'Obfuscation Password',
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
..._tlsFields,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.tuic: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.tuic,
|
||||
outboundType: 'tuic',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
_uuidField,
|
||||
_passwordField,
|
||||
SingboxProxyFormField(
|
||||
key: 'congestion_control',
|
||||
label: 'Congestion Control',
|
||||
defaultValue: 'cubic',
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'udp_relay_mode',
|
||||
label: 'UDP Relay Mode',
|
||||
defaultValue: 'native',
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'zero_rtt_handshake',
|
||||
label: 'Zero RTT Handshake',
|
||||
helperText: 'true or false.',
|
||||
kind: SingboxFieldKind.boolean,
|
||||
),
|
||||
..._tlsFields,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.ssh: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.ssh,
|
||||
outboundType: 'ssh',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
SingboxProxyFormField(key: 'user', label: 'User', required: true),
|
||||
_optionalPasswordField,
|
||||
SingboxProxyFormField(
|
||||
key: 'private_key',
|
||||
label: 'Private Key',
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'private_key_passphrase',
|
||||
label: 'Private Key Passphrase',
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.wireguard: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.wireguard,
|
||||
outboundType: 'wireguard',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
SingboxProxyFormField(
|
||||
key: 'local_address',
|
||||
label: 'Local Address',
|
||||
helperText: 'One CIDR per line, for example 10.0.0.2/32.',
|
||||
required: true,
|
||||
kind: SingboxFieldKind.stringList,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'peer_public_key',
|
||||
label: 'Peer Public Key',
|
||||
required: true,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'private_key',
|
||||
label: 'Private Key',
|
||||
helperText: 'Stored in secure storage, not profile JSON.',
|
||||
required: true,
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'pre_shared_key',
|
||||
label: 'Pre-shared Key',
|
||||
helperText: 'Optional. Stored in secure storage.',
|
||||
kind: SingboxFieldKind.secret,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'mtu',
|
||||
label: 'MTU',
|
||||
helperText: 'Optional. sing-box uses 1408 by default.',
|
||||
defaultValue: '1408',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
SingboxProxyFormField(
|
||||
key: 'reserved',
|
||||
label: 'Reserved Bytes',
|
||||
helperText: 'Optional. Three comma-separated numbers, e.g. 0,0,0.',
|
||||
kind: SingboxFieldKind.integerList,
|
||||
exactListLength: 3,
|
||||
minValue: 0,
|
||||
maxValue: 255,
|
||||
),
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.shadowTls: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.shadowTls,
|
||||
outboundType: 'shadowtls',
|
||||
fields: [
|
||||
_serverField,
|
||||
_serverPortField,
|
||||
SingboxProxyFormField(
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
defaultValue: '3',
|
||||
kind: SingboxFieldKind.integer,
|
||||
),
|
||||
_passwordField,
|
||||
..._tlsFields,
|
||||
..._commonAdvancedFields,
|
||||
],
|
||||
),
|
||||
SingboxProxyProfileType.anyTls: SingboxProxyFormSpec(
|
||||
type: SingboxProxyProfileType.anyTls,
|
||||
outboundType: 'anytls',
|
||||
fields: [_serverField, _serverPortField, _passwordField, ..._dialFields],
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
|
||||
enum ProxyLogSource { singBox, tor }
|
||||
|
||||
class ProxyLogMessage with FastEquatable {
|
||||
final ProxyLogSource source;
|
||||
final String level;
|
||||
final String message;
|
||||
final int timestamp;
|
||||
final String? profileId;
|
||||
|
||||
ProxyLogMessage({
|
||||
required this.source,
|
||||
required this.level,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
this.profileId,
|
||||
});
|
||||
|
||||
factory ProxyLogMessage.fromSingbox(SingboxProxyLogMessage message) {
|
||||
return ProxyLogMessage(
|
||||
source: ProxyLogSource.singBox,
|
||||
level: message.level,
|
||||
message: message.message,
|
||||
timestamp: message.timestamp,
|
||||
profileId: message.profileId,
|
||||
);
|
||||
}
|
||||
|
||||
factory ProxyLogMessage.fromTor(TorLogMessage message) {
|
||||
return ProxyLogMessage(
|
||||
source: ProxyLogSource.tor,
|
||||
level: _torSeverityToLevel(message.severity),
|
||||
message: message.message,
|
||||
timestamp: message.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
source,
|
||||
level,
|
||||
message,
|
||||
timestamp,
|
||||
profileId,
|
||||
];
|
||||
}
|
||||
|
||||
String _torSeverityToLevel(String severity) {
|
||||
return switch (severity.toUpperCase()) {
|
||||
'ERR' => 'error',
|
||||
'WARN' => 'warn',
|
||||
'DEBUG' => 'debug',
|
||||
'INFO' || 'NOTICE' => 'info',
|
||||
_ => severity.toLowerCase(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
|
||||
/// Pre-filled state handed to the create-mode editor when the user reached it
|
||||
/// through a guided method (file import, clipboard, QR, etc.).
|
||||
///
|
||||
/// Carries structured [values] for [SingboxProxyFormSpec]-driven types.
|
||||
/// [dnsOverrideJson] is applied to the editor's DNS override section when
|
||||
/// present.
|
||||
class ProxyProfileSeed with FastEquatable {
|
||||
final SingboxProxyProfileType type;
|
||||
final String? name;
|
||||
final Map<String, String> values;
|
||||
final String? dnsOverrideJson;
|
||||
|
||||
ProxyProfileSeed({
|
||||
required this.type,
|
||||
this.name,
|
||||
this.values = const {},
|
||||
this.dnsOverrideJson,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [type, name, values, dnsOverrideJson];
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
|
||||
/// Custom share-link scheme for round-tripping a WebLibre proxy profile
|
||||
/// (including any secret config) between WebLibre installs. NOT cross-app
|
||||
/// compatible — for that use the standard ss://, vless://, etc. URIs imported
|
||||
/// from the editor (and exposed by future per-protocol exporters).
|
||||
const weblibreProxyShareScheme = 'weblibre-proxy';
|
||||
|
||||
class ProxyShareEnvelope with FastEquatable {
|
||||
/// Schema version — bumped if the wrapped JSON shape ever changes.
|
||||
/// v1: name + type + config + secret.
|
||||
/// v2: also carries dnsOverrideJson so per-profile DNS settings survive
|
||||
/// share/import. Older v1 payloads stay readable (treated as no
|
||||
/// override).
|
||||
static const int currentVersion = 2;
|
||||
|
||||
final String name;
|
||||
final SingboxProxyProfileType type;
|
||||
final String configJson;
|
||||
final String? secretJson;
|
||||
final String? dnsOverrideJson;
|
||||
|
||||
ProxyShareEnvelope({
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.configJson,
|
||||
this.secretJson,
|
||||
this.dnsOverrideJson,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
name,
|
||||
type,
|
||||
configJson,
|
||||
secretJson,
|
||||
dnsOverrideJson,
|
||||
];
|
||||
}
|
||||
|
||||
/// Encodes the profile as `weblibre-proxy://<base64url(JSON)>`. The JSON body
|
||||
/// is compact (no whitespace) to keep the URL short for QR rendering once
|
||||
/// added.
|
||||
String encodeProxyShareUri(ProxyShareEnvelope envelope) {
|
||||
final body = <String, Object?>{
|
||||
'v': ProxyShareEnvelope.currentVersion,
|
||||
'name': envelope.name,
|
||||
'type': envelope.type.name,
|
||||
'config': jsonDecode(envelope.configJson),
|
||||
if (envelope.secretJson != null && envelope.secretJson!.isNotEmpty)
|
||||
'secret': jsonDecode(envelope.secretJson!),
|
||||
if (envelope.dnsOverrideJson != null &&
|
||||
envelope.dnsOverrideJson!.isNotEmpty)
|
||||
'dnsOverride': jsonDecode(envelope.dnsOverrideJson!),
|
||||
};
|
||||
final encoded = base64UrlEncode(utf8.encode(jsonEncode(body)));
|
||||
return '$weblibreProxyShareScheme://$encoded';
|
||||
}
|
||||
|
||||
/// Decodes a `weblibre-proxy://...` share URI. Throws [FormatException] on
|
||||
/// malformed input or version mismatch.
|
||||
const _shareUriPrefix = '$weblibreProxyShareScheme://';
|
||||
|
||||
ProxyShareEnvelope decodeProxyShareUri(String rawUri) {
|
||||
final trimmed = rawUri.trim();
|
||||
if (!trimmed.startsWith(_shareUriPrefix)) {
|
||||
throw const FormatException(
|
||||
'Not a WebLibre proxy share URI (expected scheme $weblibreProxyShareScheme).',
|
||||
);
|
||||
}
|
||||
final payload = trimmed.substring(_shareUriPrefix.length);
|
||||
final List<int> bytes;
|
||||
try {
|
||||
bytes = base64Url.decode(base64Url.normalize(payload));
|
||||
} on FormatException {
|
||||
throw const FormatException('Share URI payload is not valid base64url.');
|
||||
}
|
||||
|
||||
final dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(utf8.decode(bytes));
|
||||
} on FormatException {
|
||||
throw const FormatException('Share URI payload is not valid JSON.');
|
||||
}
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('Share URI payload must be a JSON object.');
|
||||
}
|
||||
|
||||
final version = decoded['v'];
|
||||
if (version is! int ||
|
||||
version < 1 ||
|
||||
version > ProxyShareEnvelope.currentVersion) {
|
||||
throw FormatException('Unsupported share URI version: $version.');
|
||||
}
|
||||
|
||||
final name = decoded['name'];
|
||||
final typeName = decoded['type'];
|
||||
final config = decoded['config'];
|
||||
final secret = decoded['secret'];
|
||||
final dnsOverride = decoded['dnsOverride'];
|
||||
|
||||
if (name is! String || typeName is! String || config == null) {
|
||||
throw const FormatException('Share URI is missing required fields.');
|
||||
}
|
||||
final type = SingboxProxyProfileType.values
|
||||
.where((value) => value.name == typeName)
|
||||
.firstOrNull;
|
||||
if (type == null) {
|
||||
throw FormatException('Unknown profile type: $typeName.');
|
||||
}
|
||||
|
||||
return ProxyShareEnvelope(
|
||||
name: name,
|
||||
type: type,
|
||||
configJson: jsonEncode(config),
|
||||
secretJson: secret == null ? null : jsonEncode(secret),
|
||||
dnsOverrideJson: dnsOverride == null ? null : jsonEncode(dnsOverride),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
|
||||
extension ProxyProfileExt on ProxyProfile {
|
||||
ProxyConnectionId get proxyConnection => SingboxProxyConnectionId(id);
|
||||
|
||||
String get proxyConnectionId => proxyConnection.encode();
|
||||
|
||||
SingboxProxyProfile toRuntimeProfile({String? secretJson}) {
|
||||
return SingboxProxyProfile(
|
||||
id: proxyConnectionId,
|
||||
name: name,
|
||||
type: type,
|
||||
configJson: configJson,
|
||||
secretJson: secretJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:weblibre/features/proxy/data/parsers/host_port.dart';
|
||||
|
||||
class WireguardConfigImport with FastEquatable {
|
||||
/// Values that seed the shared [SingboxProxyFormSpec]-driven WireGuard form.
|
||||
final Map<String, String> values;
|
||||
|
||||
/// Newline-separated DNS servers parsed from the WireGuard `[Interface] DNS`
|
||||
/// line. Not round-tripped through sing-box outbound JSON because DNS lives
|
||||
/// in the top-level `dns` block and is surfaced as a per-profile override.
|
||||
final String dns;
|
||||
|
||||
WireguardConfigImport({required this.values, this.dns = ''});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [values, dns];
|
||||
|
||||
factory WireguardConfigImport.fromConfigText(String configText) {
|
||||
final sections = _parseWireguardConfig(configText);
|
||||
final interface = sections['interface'];
|
||||
final peer = sections['peer'];
|
||||
if (interface == null || peer == null) {
|
||||
throw const FormatException(
|
||||
'WireGuard config must contain [Interface] and [Peer] sections.',
|
||||
);
|
||||
}
|
||||
|
||||
final rawEndpoint = (peer['endpoint'] ?? '').trim();
|
||||
final endpoint = rawEndpoint.isEmpty
|
||||
? (host: '', port: '')
|
||||
: parseHostPort(
|
||||
rawEndpoint,
|
||||
invalidMessage: 'WireGuard endpoint must be host:port.',
|
||||
);
|
||||
final mtu = interface['mtu']?.trim();
|
||||
|
||||
return WireguardConfigImport(
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'local_address': _wireguardListValue(interface['address']),
|
||||
'private_key': interface['privatekey']?.trim() ?? '',
|
||||
'peer_public_key': peer['publickey']?.trim() ?? '',
|
||||
'pre_shared_key': peer['presharedkey']?.trim() ?? '',
|
||||
'mtu': mtu == null || mtu.isEmpty ? '1408' : mtu,
|
||||
},
|
||||
dns: _wireguardListValue(interface['dns']),
|
||||
);
|
||||
}
|
||||
|
||||
/// First parsed DNS entry as a sing-box-compatible address, or null when
|
||||
/// the imported config had no `DNS = …` line. Bare IPs become `udp://<ip>`
|
||||
/// (sing-box's plain-UDP scheme); anything already containing `://` is kept
|
||||
/// verbatim so users can paste `https://…/dns-query` etc.
|
||||
String? get primaryDnsAddress {
|
||||
final entries = _splitList(dns);
|
||||
if (entries.isEmpty) return null;
|
||||
final first = entries.first;
|
||||
if (first.contains('://')) return first;
|
||||
return 'udp://$first';
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _splitList(String value) {
|
||||
return value
|
||||
.replaceAll('[', '')
|
||||
.replaceAll(']', '')
|
||||
.split(RegExp(r'[\n,]'))
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<String, Map<String, String>> _parseWireguardConfig(String configText) {
|
||||
final sections = <String, Map<String, String>>{};
|
||||
String? currentSection;
|
||||
|
||||
for (final rawLine in const LineSplitter().convert(configText)) {
|
||||
final line = _stripWireguardComment(rawLine).trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
currentSection = line.substring(1, line.length - 1).trim().toLowerCase();
|
||||
if (currentSection.isEmpty) {
|
||||
throw const FormatException('WireGuard config contains empty section.');
|
||||
}
|
||||
sections.putIfAbsent(currentSection, () => <String, String>{});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection == null) {
|
||||
throw const FormatException(
|
||||
'WireGuard config entries must be inside a section.',
|
||||
);
|
||||
}
|
||||
|
||||
final separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 1) {
|
||||
throw FormatException('Invalid WireGuard config line: $rawLine');
|
||||
}
|
||||
|
||||
final key = line.substring(0, separatorIndex).trim().toLowerCase();
|
||||
final value = line.substring(separatorIndex + 1).trim();
|
||||
if (key.isEmpty) {
|
||||
throw FormatException('Invalid WireGuard config line: $rawLine');
|
||||
}
|
||||
sections[currentSection]![key] = value;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
String _stripWireguardComment(String line) {
|
||||
final hashIndex = line.indexOf('#');
|
||||
final semicolonIndex = line.indexOf(';');
|
||||
final indexes = [
|
||||
if (hashIndex >= 0) hashIndex,
|
||||
if (semicolonIndex >= 0) semicolonIndex,
|
||||
];
|
||||
if (indexes.isEmpty) return line;
|
||||
indexes.sort();
|
||||
return line.substring(0, indexes.first);
|
||||
}
|
||||
|
||||
String _wireguardListValue(String? value) {
|
||||
if (value == null) return '';
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
/// Decodes URL-safe or standard base64 text, tolerating missing padding.
|
||||
String decodeBase64Text(String value) {
|
||||
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
|
||||
final padded = normalized.padRight(
|
||||
normalized.length + (4 - normalized.length % 4) % 4,
|
||||
'=',
|
||||
);
|
||||
return utf8.decode(base64Decode(padded));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
typedef HostPort = ({String host, String port});
|
||||
|
||||
/// Parses a `host:port` endpoint. Accepts bracketed IPv6 syntax
|
||||
/// (`[2001:db8::1]:443`). Throws [FormatException] on invalid input.
|
||||
HostPort parseHostPort(
|
||||
String rawEndpoint, {
|
||||
String invalidMessage = 'Endpoint must be host:port.',
|
||||
}) {
|
||||
final endpoint = rawEndpoint.trim();
|
||||
if (endpoint.startsWith('[')) {
|
||||
final closingIndex = endpoint.indexOf(']');
|
||||
if (closingIndex < 0 || closingIndex == endpoint.length - 1) {
|
||||
throw FormatException(invalidMessage);
|
||||
}
|
||||
final remainder = endpoint.substring(closingIndex + 1);
|
||||
if (!remainder.startsWith(':')) {
|
||||
throw FormatException(invalidMessage);
|
||||
}
|
||||
return (
|
||||
host: endpoint.substring(1, closingIndex),
|
||||
port: remainder.substring(1),
|
||||
);
|
||||
}
|
||||
|
||||
final separatorIndex = endpoint.lastIndexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex == endpoint.length - 1) {
|
||||
throw FormatException(invalidMessage);
|
||||
}
|
||||
final host = endpoint.substring(0, separatorIndex);
|
||||
if (host.contains(':')) {
|
||||
throw const FormatException(
|
||||
'IPv6 endpoints must use [address]:port syntax.',
|
||||
);
|
||||
}
|
||||
return (host: host, port: endpoint.substring(separatorIndex + 1));
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:weblibre/features/proxy/data/parsers/base64_text.dart';
|
||||
import 'package:weblibre/features/proxy/data/parsers/host_port.dart';
|
||||
|
||||
/// Form-spec-shaped result of parsing a single proxy URI (ss://, vless://, …).
|
||||
class SingboxProxyUriImport with FastEquatable {
|
||||
final SingboxProxyProfileType type;
|
||||
final String? name;
|
||||
final Map<String, String> values;
|
||||
|
||||
SingboxProxyUriImport({required this.type, required this.values, this.name});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [type, name, values];
|
||||
}
|
||||
|
||||
/// Dispatches on the URI scheme and delegates to the matching importer.
|
||||
/// Throws [FormatException] for unknown or malformed schemes.
|
||||
SingboxProxyUriImport importSingboxProxyUri(String rawUri) {
|
||||
final trimmedUri = rawUri.trim();
|
||||
final schemeEnd = trimmedUri.indexOf('://');
|
||||
if (schemeEnd <= 0) {
|
||||
throw const FormatException('Proxy URI must include a scheme.');
|
||||
}
|
||||
|
||||
final scheme = trimmedUri.substring(0, schemeEnd).toLowerCase();
|
||||
return switch (scheme) {
|
||||
'ss' => _importShadowsocksUri(trimmedUri),
|
||||
'socks' || 'socks5' => _importSocksUri(trimmedUri),
|
||||
'http' || 'https' => _importHttpUri(trimmedUri),
|
||||
'trojan' => _importTrojanUri(trimmedUri),
|
||||
'vless' => _importVlessUri(trimmedUri),
|
||||
'vmess' => _importVmessUri(trimmedUri),
|
||||
'hysteria2' || 'hy2' => _importHysteria2Uri(trimmedUri),
|
||||
'tuic' => _importTuicUri(trimmedUri),
|
||||
_ => throw FormatException('Unsupported proxy URI scheme: $scheme'),
|
||||
};
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importShadowsocksUri(String rawUri) {
|
||||
final payload = rawUri.substring('ss://'.length);
|
||||
final fragmentIndex = payload.indexOf('#');
|
||||
final withoutFragment = fragmentIndex >= 0
|
||||
? payload.substring(0, fragmentIndex)
|
||||
: payload;
|
||||
final name = fragmentIndex >= 0
|
||||
? Uri.decodeComponent(payload.substring(fragmentIndex + 1))
|
||||
: null;
|
||||
final withoutQuery = withoutFragment.split('?').first;
|
||||
final decodedPayload = withoutQuery.contains('@')
|
||||
? withoutQuery
|
||||
: decodeBase64Text(withoutQuery);
|
||||
final atIndex = decodedPayload.lastIndexOf('@');
|
||||
if (atIndex <= 0 || atIndex == decodedPayload.length - 1) {
|
||||
throw const FormatException('Shadowsocks URI must include credentials.');
|
||||
}
|
||||
|
||||
final rawCredentials = decodedPayload.substring(0, atIndex);
|
||||
final credentials = rawCredentials.contains(':')
|
||||
? rawCredentials
|
||||
: decodeBase64Text(rawCredentials);
|
||||
final credentialSeparator = credentials.indexOf(':');
|
||||
if (credentialSeparator <= 0) {
|
||||
throw const FormatException(
|
||||
'Shadowsocks URI credentials must be method:password.',
|
||||
);
|
||||
}
|
||||
final endpoint = parseHostPort(
|
||||
decodedPayload.substring(atIndex + 1),
|
||||
invalidMessage: 'Proxy URI endpoint must be host:port.',
|
||||
);
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.shadowsocks,
|
||||
name: _nonEmptyName(name),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'method': Uri.decodeComponent(
|
||||
credentials.substring(0, credentialSeparator),
|
||||
),
|
||||
'password': Uri.decodeComponent(
|
||||
credentials.substring(credentialSeparator + 1),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importTrojanUri(String rawUri) {
|
||||
final uri = Uri.parse(rawUri);
|
||||
final endpoint = _uriEndpoint(uri, 'Trojan');
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.trojan,
|
||||
name: _uriName(uri),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'password': Uri.decodeComponent(uri.userInfo),
|
||||
'tls.enabled': 'true',
|
||||
..._tlsImportValues(uri),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importVlessUri(String rawUri) {
|
||||
final uri = Uri.parse(rawUri);
|
||||
final endpoint = _uriEndpoint(uri, 'VLESS');
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.vless,
|
||||
name: _uriName(uri),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'uuid': Uri.decodeComponent(uri.userInfo),
|
||||
'flow': uri.queryParameters['flow'] ?? '',
|
||||
..._tlsImportValues(uri),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importSocksUri(String rawUri) {
|
||||
final uri = Uri.parse(rawUri);
|
||||
final endpoint = _uriHostPort(uri, 'SOCKS');
|
||||
final userInfo = _splitUserInfo(uri.userInfo);
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.socks,
|
||||
name: _uriName(uri),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'version': '5',
|
||||
'username': userInfo.username,
|
||||
'password': userInfo.password,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importHttpUri(String rawUri) {
|
||||
final uri = Uri.parse(rawUri);
|
||||
final endpoint = _uriHostPort(uri, 'HTTP');
|
||||
final userInfo = _splitUserInfo(uri.userInfo);
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.http,
|
||||
name: _uriName(uri),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'username': userInfo.username,
|
||||
'password': userInfo.password,
|
||||
if (uri.scheme.toLowerCase() == 'https') 'tls.enabled': 'true',
|
||||
..._tlsImportValues(uri),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importHysteria2Uri(String rawUri) {
|
||||
final uri = Uri.parse(rawUri);
|
||||
final endpoint = _uriEndpoint(uri, 'Hysteria2');
|
||||
final query = uri.queryParameters;
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.hysteria2,
|
||||
name: _uriName(uri),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'password': Uri.decodeComponent(uri.userInfo),
|
||||
'obfs.type': query['obfs'] ?? '',
|
||||
'obfs.password': query['obfs-password'] ?? query['obfs_password'] ?? '',
|
||||
'tls.enabled': 'true',
|
||||
..._tlsImportValues(uri),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importTuicUri(String rawUri) {
|
||||
final uri = Uri.parse(rawUri);
|
||||
final endpoint = _uriEndpoint(uri, 'TUIC');
|
||||
final userInfo = _splitUserInfo(uri.userInfo);
|
||||
final query = uri.queryParameters;
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.tuic,
|
||||
name: _uriName(uri),
|
||||
values: {
|
||||
'server': endpoint.host,
|
||||
'server_port': endpoint.port,
|
||||
'uuid': userInfo.username,
|
||||
'password': userInfo.password,
|
||||
'congestion_control':
|
||||
query['congestion_control'] ?? query['congestion'] ?? 'cubic',
|
||||
'udp_relay_mode': query['udp_relay_mode'] ?? 'native',
|
||||
'tls.enabled': 'true',
|
||||
..._tlsImportValues(uri),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyUriImport _importVmessUri(String rawUri) {
|
||||
final payload = rawUri.substring('vmess://'.length).split('#').first.trim();
|
||||
final decoded = jsonDecode(decodeBase64Text(payload)) as Object?;
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('VMess URI payload must be a JSON object.');
|
||||
}
|
||||
|
||||
final server = _stringValue(decoded['add'], '');
|
||||
final port = _stringValue(decoded['port'], '');
|
||||
final uuid = _stringValue(decoded['id'], '');
|
||||
if (server.isEmpty || port.isEmpty || uuid.isEmpty) {
|
||||
throw const FormatException('VMess URI must include add, port, and id.');
|
||||
}
|
||||
|
||||
return SingboxProxyUriImport(
|
||||
type: SingboxProxyProfileType.vmess,
|
||||
name: _nonEmptyName(_stringValue(decoded['ps'], '')),
|
||||
values: {
|
||||
'server': server,
|
||||
'server_port': port,
|
||||
'uuid': uuid,
|
||||
'security': _stringValue(decoded['scy'], 'auto'),
|
||||
'alter_id': _stringValue(decoded['aid'], ''),
|
||||
'tls.enabled': _stringValue(decoded['tls'], '') == 'tls' ? 'true' : '',
|
||||
'tls.server_name': _stringValue(decoded['sni'], ''),
|
||||
'transport.type': _stringValue(decoded['net'], ''),
|
||||
'transport.path': _stringValue(decoded['path'], ''),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
HostPort _uriEndpoint(Uri uri, String label) {
|
||||
final endpoint = _uriHostPort(uri, label);
|
||||
if (uri.userInfo.isEmpty) {
|
||||
throw FormatException('$label URI must include credentials.');
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
HostPort _uriHostPort(Uri uri, String label) {
|
||||
final hasExplicitOrDefaultPort = uri.hasPort || uri.port > 0;
|
||||
if (uri.host.isEmpty || !hasExplicitOrDefaultPort) {
|
||||
throw FormatException('$label URI must include host and port.');
|
||||
}
|
||||
return (host: uri.host, port: uri.port.toString());
|
||||
}
|
||||
|
||||
String? _uriName(Uri uri) {
|
||||
if (!uri.hasFragment) return null;
|
||||
return _nonEmptyName(Uri.decodeComponent(uri.fragment));
|
||||
}
|
||||
|
||||
String? _nonEmptyName(String? name) {
|
||||
final trimmed = name?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
({String username, String password}) _splitUserInfo(String userInfo) {
|
||||
if (userInfo.isEmpty) return (username: '', password: '');
|
||||
final separatorIndex = userInfo.indexOf(':');
|
||||
if (separatorIndex < 0) {
|
||||
return (username: Uri.decodeComponent(userInfo), password: '');
|
||||
}
|
||||
|
||||
return (
|
||||
username: Uri.decodeComponent(userInfo.substring(0, separatorIndex)),
|
||||
password: Uri.decodeComponent(userInfo.substring(separatorIndex + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _tlsImportValues(Uri uri) {
|
||||
final query = uri.queryParameters;
|
||||
final values = <String, String>{};
|
||||
final security = query['security'] ?? query['tls'];
|
||||
if (security == 'tls' || security == '1' || security == 'true') {
|
||||
values['tls.enabled'] = 'true';
|
||||
}
|
||||
final serverName = query['sni'] ?? query['peer'] ?? query['server_name'];
|
||||
if (serverName != null && serverName.isNotEmpty) {
|
||||
values['tls.server_name'] = serverName;
|
||||
}
|
||||
final insecure =
|
||||
query['allowInsecure'] ?? query['allow_insecure'] ?? query['insecure'];
|
||||
if (insecure != null && insecure.isNotEmpty) {
|
||||
values['tls.insecure'] = insecure;
|
||||
}
|
||||
final alpn = query['alpn'];
|
||||
if (alpn != null && alpn.isNotEmpty) values['tls.alpn'] = alpn;
|
||||
return values;
|
||||
}
|
||||
|
||||
String _stringValue(Object? value, String fallback) {
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
sealed class ProxyConnectionId {
|
||||
const ProxyConnectionId();
|
||||
|
||||
String encode();
|
||||
|
||||
static ProxyConnectionId? decode(String? raw) {
|
||||
if (raw == null) return null;
|
||||
if (raw == TorProxyConnectionId.encoded) {
|
||||
return const TorProxyConnectionId();
|
||||
}
|
||||
|
||||
if (raw.startsWith(SingboxProxyConnectionId._prefix)) {
|
||||
final profileId = raw.substring(SingboxProxyConnectionId._prefix.length);
|
||||
if (profileId.isEmpty) return null;
|
||||
return SingboxProxyConnectionId(profileId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class TorProxyConnectionId extends ProxyConnectionId {
|
||||
static const String encoded = 'tor';
|
||||
|
||||
const TorProxyConnectionId();
|
||||
|
||||
@override
|
||||
String encode() => encoded;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is TorProxyConnectionId;
|
||||
|
||||
@override
|
||||
int get hashCode => encoded.hashCode;
|
||||
}
|
||||
|
||||
final class SingboxProxyConnectionId extends ProxyConnectionId {
|
||||
static const String _prefix = 'singbox:';
|
||||
|
||||
final String profileId;
|
||||
|
||||
const SingboxProxyConnectionId(this.profileId);
|
||||
|
||||
@override
|
||||
String encode() => '$_prefix$profileId';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SingboxProxyConnectionId && other.profileId == profileId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(_prefix, profileId);
|
||||
}
|
||||
Reference in New Issue
Block a user