diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 6793529..5ef69d7 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -663,6 +663,8 @@ abstract class AppText { String get routingRuleValueHint; // 目标输入框占位 / Target input placeholder String get routingNoRules; // 暂无自定义规则 / No custom rules yet String get routingAddRule; // 添加规则 / Add rule + String get routingSaveFailed; // 保存失败,请重试 / Save failed, please try again + String get routingRuleValueInvalid; // 格式不正确 / Invalid format String get routingImport; // 从文本导入 / Import from text String get routingReset; // 重置默认 / Reset defaults String get routingResetConfirmTitle; // 重置为默认规则? / Reset to defaults? diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index e1ec154..fd4e9c7 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -571,6 +571,10 @@ class StringsEn extends AppText { @override String get routingAddRule => 'Add rule'; @override + String get routingSaveFailed => 'Save failed, please try again'; + @override + String get routingRuleValueInvalid => 'Invalid format'; + @override String get routingImport => 'Import from text'; @override String get routingReset => 'Reset defaults'; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index ee76fbd..81820b9 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -571,6 +571,10 @@ class StringsEs extends AppText { @override String get routingAddRule => 'Añadir regla'; @override + String get routingSaveFailed => 'Error al guardar, inténtalo de nuevo'; + @override + String get routingRuleValueInvalid => 'Formato no válido'; + @override String get routingImport => 'Importar desde texto'; @override String get routingReset => 'Restablecer'; diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 14ac49f..c35faff 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -571,6 +571,10 @@ class StringsJa extends AppText { @override String get routingAddRule => 'ルールを追加'; @override + String get routingSaveFailed => '保存に失敗しました。もう一度お試しください'; + @override + String get routingRuleValueInvalid => '形式が正しくありません'; + @override String get routingImport => 'テキストから読み込み'; @override String get routingReset => 'デフォルトに戻す'; diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 85f0637..64e5eb9 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -571,6 +571,10 @@ class StringsKo extends AppText { @override String get routingAddRule => '규칙 추가'; @override + String get routingSaveFailed => '저장에 실패했습니다. 다시 시도하세요'; + @override + String get routingRuleValueInvalid => '형식이 올바르지 않습니다'; + @override String get routingImport => '텍스트에서 가져오기'; @override String get routingReset => '기본값 재설정'; diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index e5b1627..db82fd6 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -571,6 +571,10 @@ class StringsRu extends AppText { @override String get routingAddRule => 'Добавить правило'; @override + String get routingSaveFailed => 'Не удалось сохранить, попробуйте ещё раз'; + @override + String get routingRuleValueInvalid => 'Неверный формат'; + @override String get routingImport => 'Импорт из текста'; @override String get routingReset => 'Сбросить настройки'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 10bafe4..5a5f8ab 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -571,6 +571,10 @@ class StringsZh extends AppText { @override String get routingAddRule => '添加规则'; @override + String get routingSaveFailed => '保存失败,请重试'; + @override + String get routingRuleValueInvalid => '格式不正确'; + @override String get routingImport => '从文本导入'; @override String get routingReset => '重置默认'; diff --git a/client/lib/widgets/routing_screen.dart b/client/lib/widgets/routing_screen.dart index aaa6058..db921ee 100644 --- a/client/lib/widgets/routing_screen.dart +++ b/client/lib/widgets/routing_screen.dart @@ -13,12 +13,15 @@ // - 域名类规则(domain/domain_suffix/domain_keyword)命中 RoutingProfile. // systemLockedDomains(FT-A 起 GET /v1/me/routing 下发的私有服务域名清单, // PANGOLIN_PRIVATE_SPLIT_DOMAINS)——服务端渲染时恒强制走隧道,与用户规则动作冲突。 +import 'dart:io' show InternetAddress, InternetAddressType; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../models/routing_profile.dart'; import '../pangolin_theme.dart'; +import '../services/auth_api.dart' show AuthApiException; import '../state/app_providers.dart'; import '../state/routing_provider.dart'; import 'pangolin_button.dart'; @@ -61,6 +64,25 @@ class RoutingScreen extends ConsumerWidget { } } +/// 变更类操作统一守卫:await + 失败弹 SnackBar(不再静默回滚 / 抛未捕获异步异常)。 +/// _persist 失败已回滚 state 并 rethrow,这里兜住并把原因告知用户;AuthApiException +/// 带服务端双语文案(含 routing_invalid 校验错误),其余异常回退通用「保存失败」。 +Future _guardSave(BuildContext context, AppText t, Future Function() op) async { + try { + await op(); + } on AuthApiException catch (e) { + if (!context.mounted) return; + _showRoutingError(context, t.lang == AppLang.zh ? e.messageZh : e.messageEn); + } catch (_) { + if (!context.mounted) return; + _showRoutingError(context, t.routingSaveFailed); + } +} + +void _showRoutingError(BuildContext context, String msg) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); +} + class _RoutingBody extends ConsumerWidget { const _RoutingBody({required this.t, required this.profile}); final AppText t; @@ -86,7 +108,7 @@ class _RoutingBody extends ConsumerWidget { (icon: PangolinIcons.arrowRight, label: t.routingModeDirect), ], selectedIndex: selectedIdx, - onChanged: (i) => notifier.setMode(_kModeValues[i]), + onChanged: (i) => _guardSave(context, t, () => notifier.setMode(_kModeValues[i])), ), const SizedBox(height: 8), Text(t.routingModeNote, style: PangolinText.caption.copyWith(color: c.fg3, height: 1.5)), @@ -101,7 +123,7 @@ class _RoutingBody extends ConsumerWidget { title: t.routingCnDirect, sub: 'GeoIP / GeoSite CN', value: profile.builtin.chinaDirect, - onChanged: (v) => notifier.setBuiltin(profile.builtin.copyWith(chinaDirect: v)), + onChanged: (v) => _guardSave(context, t, () => notifier.setBuiltin(profile.builtin.copyWith(chinaDirect: v))), last: false, ), _forcedRow(c, icon: PangolinIcons.home, title: t.routingLanDirect, sub: t.routingLanForced, pill: t.routingForcedPill), @@ -135,14 +157,14 @@ class _RoutingBody extends ConsumerWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, - onReorder: (oldIndex, newIndex) => notifier.reorder(oldIndex, newIndex), + onReorder: (oldIndex, newIndex) => _guardSave(context, t, () => notifier.reorder(oldIndex, newIndex)), children: [ for (var i = 0; i < profile.rules.length; i++) _ruleRow( context, c, t, i, profile.rules[i], _conflictFor(profile.rules, i, profile.systemLockedDomains), i < profile.rules.length - 1, - onDelete: () => notifier.removeRule(i)), + onDelete: () => _guardSave(context, t, () => notifier.removeRule(i))), ], ), ), @@ -220,13 +242,15 @@ class _RoutingBody extends ConsumerWidget { ), ); if (ok != true) return; - await ref.read(routingProfileProvider.notifier).resetToDefault(); + if (!context.mounted) return; + await _guardSave(context, t, () => ref.read(routingProfileProvider.notifier).resetToDefault()); } Future _openAddDialog(BuildContext context, WidgetRef ref, AppText t) async { final rule = await showDialog(context: context, builder: (_) => _AddRuleDialog(t: t)); if (rule == null) return; - await ref.read(routingProfileProvider.notifier).addRule(rule); + if (!context.mounted) return; + await _guardSave(context, t, () => ref.read(routingProfileProvider.notifier).addRule(rule)); } Widget _builtinToggleRow( @@ -446,11 +470,38 @@ class _AddRuleDialogState extends State<_AddRuleDialog> { super.dispose(); } + /// 字段级预校验:只拦明显格式错(空由按钮禁用兜底),避免"乐观显示→服务端 400→静默消失"。 + /// 语义级(保留段/catch-all direct)仍由服务端权威判定,经 SnackBar 呈现。返回 null=通过。 + String? _valueError(AppText t) { + final v = _value.text.trim(); + if (v.isEmpty) return null; + switch (_type) { + case 'ip_cidr': + if (!_isValidCidr(v)) return t.routingRuleValueInvalid; + case 'geoip': + case 'geosite': + if (v.toLowerCase() != 'cn') return t.routingRuleValueInvalid; // geo 白名单仅 cn + } + return null; + } + + bool _isValidCidr(String s) { + final parts = s.split('/'); + if (parts.length != 2) return false; + final prefix = int.tryParse(parts[1]); + if (prefix == null) return false; + final addr = InternetAddress.tryParse(parts[0]); + if (addr == null) return false; + final max = addr.type == InternetAddressType.IPv6 ? 128 : 32; + return prefix >= 0 && prefix <= max; + } + @override Widget build(BuildContext context) { final c = context.pangolin; final t = widget.t; - final canSave = _value.text.trim().isNotEmpty; + final valueError = _valueError(t); + final canSave = _value.text.trim().isNotEmpty && valueError == null; return AlertDialog( backgroundColor: c.surface, @@ -479,6 +530,11 @@ class _AddRuleDialogState extends State<_AddRuleDialog> { onChanged: (_) => setState(() {}), ), ), + if (valueError != null) + Padding( + padding: const EdgeInsets.only(top: 6, left: 4), + child: Text(valueError, style: PangolinText.caption.copyWith(color: c.danger)), + ), const SizedBox(height: 14), SegSwitch( options: [ diff --git a/design/i18n/strings.json b/design/i18n/strings.json index ec41aaf..d33423f 100644 --- a/design/i18n/strings.json +++ b/design/i18n/strings.json @@ -2241,6 +2241,22 @@ "ru": "Добавить правило", "es": "Añadir regla" }, + "routingSaveFailed": { + "zh": "保存失败,请重试", + "en": "Save failed, please try again", + "ja": "保存に失敗しました。もう一度お試しください", + "ko": "저장에 실패했습니다. 다시 시도하세요", + "ru": "Не удалось сохранить, попробуйте ещё раз", + "es": "Error al guardar, inténtalo de nuevo" + }, + "routingRuleValueInvalid": { + "zh": "格式不正确", + "en": "Invalid format", + "ja": "形式が正しくありません", + "ko": "형식이 올바르지 않습니다", + "ru": "Неверный формат", + "es": "Formato no válido" + }, "routingImport": { "zh": "从文本导入", "en": "Import from text", diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index abe1d83..e537e21 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -78,7 +78,9 @@ func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string, hasDomainRule = true case "ip_cidr": rules = append(rules, map[string]any{"ip_cidr": []string{r.Value}, "outbound": outbound}) - if outbound == "direct" { + // 纵深防护:只有安全的 direct ip_cidr 才并入 route_exclude_address。 + // catch-all/隧道保留段会静默旁路系统层(Validate 已在写入路径拦,这里兜底历史坏行)。 + if outbound == "direct" && routing.SafeToExclude(r.Value) { extraExclude = append(extraExclude, r.Value) } case "geoip", "geosite": diff --git a/server/internal/routing/profile.go b/server/internal/routing/profile.go index a490b65..f74dbc6 100644 --- a/server/internal/routing/profile.go +++ b/server/internal/routing/profile.go @@ -71,6 +71,40 @@ var validType = map[string]bool{"domain": true, "domain_suffix": true, "domain_k var validAction = map[string]bool{"direct": true, "proxy": true, "reject": true} var geoWhitelist = map[string]bool{"cn": true} // geoip/geosite 仅自托管 cn +// reservedTunnelNet 是隧道/内部 DNS 保留段(含隧道 DNS 172.19.0.2)。direct 的 +// ip_cidr 规则会并入 TUN 入站 route_exclude_address(OS/auto_route 层,位于系统 +// 强制层之下),若排除此段会静默旁路 hijack-dns 与整条隧道 —— 见 Validate 里的守卫。 +var reservedTunnelNet = mustCIDR("172.16.0.0/12") + +func mustCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} + +// cidrsOverlap 判断两个对齐的 CIDR 块是否相交(其一的网络地址落在另一之内)。 +// 家族不匹配(v4 vs v6)时 net.IPNet.Contains 返回 false,故混用安全。 +func cidrsOverlap(a, b *net.IPNet) bool { + return a.Contains(b.IP) || b.Contains(a.IP) +} + +// SafeToExclude 报告一条 direct 的 ip_cidr 值是否可安全并入 TUN route_exclude_address。 +// 与 Validate 的守卫同源:catch-all 或与隧道/DNS 保留段重叠一律拒绝。渲染层(clientconfig) +// 在合并 extraExclude 前调用它,作纵深防护——即便有写入闸之前存下的历史坏行,也不会 +// 让它静默旁路系统强制层。 +func SafeToExclude(cidr string) bool { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + if ones, _ := ipnet.Mask.Size(); ones == 0 { + return false + } + return !cidrsOverlap(ipnet, reservedTunnelNet) +} + // Validate checks the profile against the type/action whitelist, CIDR // syntax, the geo set whitelist, and the rule count cap. It returns an empty // (non-nil) slice when the profile is valid — so JSON encoding produces `[]` @@ -99,8 +133,18 @@ func (p *Profile) Validate() []FieldError { } switch r.Type { case "ip_cidr": - if _, _, err := net.ParseCIDR(r.Value); err != nil { + _, ipnet, err := net.ParseCIDR(r.Value) + if err != nil { errs = append(errs, FieldError{i, "value", "invalid CIDR"}) + } else if r.Action == "direct" { + // direct 的 ip_cidr 并入 TUN route_exclude_address(系统层之下)。 + // catch-all(/0)会整条旁路隧道;与隧道/DNS 保留段重叠会破坏隧道 DNS。 + // 二者都能静默越过系统强制层,拒绝之(proxy/reject 不入排除表,不受限)。 + if ones, _ := ipnet.Mask.Size(); ones == 0 { + errs = append(errs, FieldError{i, "value", "direct ip_cidr must not be catch-all (0.0.0.0/0 or ::/0)"}) + } else if cidrsOverlap(ipnet, reservedTunnelNet) { + errs = append(errs, FieldError{i, "value", "direct ip_cidr must not overlap reserved tunnel range 172.16.0.0/12"}) + } } case "geoip", "geosite": if !geoWhitelist[strings.ToLower(r.Value)] { diff --git a/server/internal/routing/profile_test.go b/server/internal/routing/profile_test.go index 32040ac..bba3ed2 100644 --- a/server/internal/routing/profile_test.go +++ b/server/internal/routing/profile_test.go @@ -42,6 +42,37 @@ func TestValidate(t *testing.T) { } } +func TestValidateDirectIPCIDRReservedGuard(t *testing.T) { + // direct 的 ip_cidr 会并入 TUN route_exclude_address(系统层之下),catch-all 或 + // 与隧道/DNS 保留段(172.16.0.0/12)重叠会静默旁路 hijack-dns/隧道 → 必须拒绝。 + rejected := []Rule{ + {Type: "ip_cidr", Value: "0.0.0.0/0", Action: "direct", Enabled: true}, + {Type: "ip_cidr", Value: "::/0", Action: "direct", Enabled: true}, + {Type: "ip_cidr", Value: "172.16.0.0/12", Action: "direct", Enabled: true}, + {Type: "ip_cidr", Value: "172.19.0.0/16", Action: "direct", Enabled: true}, // 含隧道 DNS 172.19.0.2 + } + for _, r := range rejected { + p := Default() + p.Rules = []Rule{r} + if errs := p.Validate(); len(errs) == 0 { + t.Errorf("direct ip_cidr %q 应被拒,却无报错", r.Value) + } + } + // proxy/reject 不入排除表故不受限;不与保留段重叠的 direct 允许。 + allowed := []Rule{ + {Type: "ip_cidr", Value: "0.0.0.0/0", Action: "proxy", Enabled: true}, + {Type: "ip_cidr", Value: "10.0.0.0/8", Action: "direct", Enabled: true}, + {Type: "ip_cidr", Value: "8.8.8.8/32", Action: "direct", Enabled: true}, + } + for _, r := range allowed { + p := Default() + p.Rules = []Rule{r} + if errs := p.Validate(); len(errs) != 0 { + t.Errorf("ip_cidr %q action=%s 应允许,却报错 %v", r.Value, r.Action, errs) + } + } +} + func TestValidateCountLimit(t *testing.T) { p := Default() for i := 0; i < 201; i++ {