From 9f867b19da0961b2af45c35b00caaf2df895e3e3 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 22:41:14 +0800 Subject: [PATCH 01/97] First Commmit --- .fpm_openwrt | 31 + .fpm_pacman | 23 + .fpm_systemd | 26 + .gitignore | 23 + .gitmodules | 6 + .golangci.yml | 64 + Dockerfile | 26 + Dockerfile.binary | 14 + LICENSE | 17 + Makefile | 276 + README.md | 39 + adapter/certificate.go | 21 + adapter/certificate/adapter.go | 21 + adapter/certificate/manager.go | 158 + adapter/certificate/registry.go | 72 + adapter/certificate_provider.go | 38 + adapter/connections.go | 18 + adapter/dns.go | 98 + adapter/endpoint.go | 28 + adapter/endpoint/adapter.go | 43 + adapter/endpoint/manager.go | 161 + adapter/endpoint/registry.go | 72 + adapter/experimental.go | 152 + adapter/fakeip.go | 31 + adapter/fakeip_metadata.go | 50 + adapter/handler.go | 61 + adapter/inbound.go | 195 + adapter/inbound/adapter.go | 21 + adapter/inbound/manager.go | 163 + adapter/inbound/registry.go | 72 + adapter/inbound_test.go | 45 + adapter/lifecycle.go | 102 + adapter/lifecycle_legacy.go | 52 + adapter/neighbor.go | 23 + adapter/network.go | 60 + adapter/outbound.go | 47 + adapter/outbound/adapter.go | 45 + adapter/outbound/manager.go | 317 ++ adapter/outbound/registry.go | 72 + adapter/platform.go | 74 + adapter/prestart.go | 1 + adapter/router.go | 122 + adapter/rule.go | 40 + adapter/service.go | 27 + adapter/service/adapter.go | 21 + adapter/service/manager.go | 158 + adapter/service/registry.go | 72 + adapter/ssm.go | 18 + adapter/tailscale.go | 49 + adapter/time.go | 8 + adapter/upstream.go | 168 + adapter/upstream_legacy.go | 234 + adapter/v2ray.go | 24 + box.go | 609 +++ cmd/internal/app_store_connect/main.go | 452 ++ cmd/internal/build/main.go | 26 + cmd/internal/build_libbox/main.go | 245 + cmd/internal/build_shared/sdk.go | 106 + cmd/internal/build_shared/tag.go | 38 + cmd/internal/format_docs/main.go | 117 + cmd/internal/protogen/main.go | 218 + cmd/internal/read_tag/main.go | 71 + cmd/internal/tun_bench/main.go | 284 + cmd/internal/update_android_version/main.go | 84 + cmd/internal/update_apple_version/main.go | 145 + cmd/internal/update_certificates/main.go | 166 + cmd/sing-box/cmd.go | 71 + cmd/sing-box/cmd_check.go | 43 + cmd/sing-box/cmd_format.go | 75 + cmd/sing-box/cmd_generate.go | 92 + cmd/sing-box/cmd_generate_ech.go | 36 + cmd/sing-box/cmd_generate_tls.go | 40 + cmd/sing-box/cmd_generate_vapid.go | 40 + cmd/sing-box/cmd_generate_wireguard.go | 61 + cmd/sing-box/cmd_geoip.go | 43 + cmd/sing-box/cmd_geoip_export.go | 98 + cmd/sing-box/cmd_geoip_list.go | 31 + cmd/sing-box/cmd_geoip_lookup.go | 47 + cmd/sing-box/cmd_geosite.go | 41 + cmd/sing-box/cmd_geosite_export.go | 81 + cmd/sing-box/cmd_geosite_list.go | 50 + cmd/sing-box/cmd_geosite_lookup.go | 97 + cmd/sing-box/cmd_geosite_matcher.go | 56 + cmd/sing-box/cmd_merge.go | 143 + cmd/sing-box/cmd_rule_set.go | 14 + cmd/sing-box/cmd_rule_set_compile.go | 102 + cmd/sing-box/cmd_rule_set_convert.go | 89 + cmd/sing-box/cmd_rule_set_decompile.go | 101 + cmd/sing-box/cmd_rule_set_format.go | 83 + cmd/sing-box/cmd_rule_set_match.go | 105 + cmd/sing-box/cmd_rule_set_merge.go | 162 + cmd/sing-box/cmd_rule_set_upgrade.go | 95 + cmd/sing-box/cmd_run.go | 212 + cmd/sing-box/cmd_tools.go | 54 + cmd/sing-box/cmd_tools_connect.go | 73 + cmd/sing-box/cmd_tools_fetch.go | 115 + cmd/sing-box/cmd_tools_fetch_http3.go | 36 + cmd/sing-box/cmd_tools_fetch_http3_stub.go | 18 + cmd/sing-box/cmd_tools_networkquality.go | 121 + cmd/sing-box/cmd_tools_stun.go | 79 + cmd/sing-box/cmd_tools_synctime.go | 67 + cmd/sing-box/cmd_version.go | 64 + cmd/sing-box/generate_completions.go | 28 + cmd/sing-box/main.go | 11 + common/badtls/raw_conn.go | 176 + common/badtls/raw_half_conn.go | 121 + common/badtls/read_wait.go | 82 + common/badtls/read_wait_stub.go | 13 + common/badtls/registry.go | 62 + common/badtls/registry_utls.go | 56 + common/badversion/version.go | 152 + common/badversion/version_json.go | 17 + common/badversion/version_test.go | 18 + common/certificate/chrome.go | 2817 ++++++++++ common/certificate/mozilla.go | 4593 +++++++++++++++++ common/certificate/store.go | 192 + common/compatible/map.go | 58 + common/convertor/adguard/convertor.go | 458 ++ common/convertor/adguard/convertor_test.go | 145 + common/dialer/default.go | 390 ++ common/dialer/default_parallel_interface.go | 244 + common/dialer/default_parallel_network.go | 161 + common/dialer/detour.go | 85 + common/dialer/dialer.go | 152 + common/dialer/resolve.go | 194 + common/dialer/router.go | 33 + common/dialer/tfo.go | 180 + common/dialer/wireguard.go | 9 + common/geoip/reader.go | 38 + common/geosite/compat_test.go | 234 + common/geosite/geosite_test.go | 34 + common/geosite/reader.go | 158 + common/geosite/rule.go | 103 + common/geosite/writer.go | 73 + common/interrupt/conn.go | 75 + common/interrupt/context.go | 13 + common/interrupt/group.go | 52 + common/ja3/LICENSE | 29 + common/ja3/README.md | 3 + common/ja3/error.go | 31 + common/ja3/ja3.go | 83 + common/ja3/parser.go | 357 ++ common/ktls/ktls.go | 133 + common/ktls/ktls_alert.go | 80 + common/ktls/ktls_cipher_suites_linux.go | 326 ++ common/ktls/ktls_close.go | 67 + common/ktls/ktls_const.go | 24 + common/ktls/ktls_handshake_messages.go | 238 + common/ktls/ktls_key_update.go | 173 + common/ktls/ktls_linux.go | 329 ++ common/ktls/ktls_prf.go | 24 + common/ktls/ktls_read.go | 293 ++ common/ktls/ktls_read_wait.go | 41 + common/ktls/ktls_stub_nolinkname.go | 15 + common/ktls/ktls_stub_nonlinux.go | 15 + common/ktls/ktls_stub_oldgo.go | 15 + common/ktls/ktls_write.go | 154 + common/listener/listener.go | 172 + common/listener/listener_tcp.go | 111 + common/listener/listener_udp.go | 207 + common/mux/client.go | 59 + common/mux/router.go | 80 + common/networkquality/http.go | 142 + common/networkquality/http3.go | 55 + common/networkquality/http3_stub.go | 12 + common/networkquality/networkquality.go | 1413 +++++ common/pipelistener/listener.go | 57 + common/process/searcher.go | 39 + common/process/searcher_android.go | 48 + common/process/searcher_darwin.go | 45 + common/process/searcher_darwin_shared.go | 269 + common/process/searcher_linux.go | 85 + common/process/searcher_linux_shared.go | 383 ++ common/process/searcher_linux_shared_test.go | 60 + common/process/searcher_stub.go | 11 + common/process/searcher_windows.go | 66 + common/redir/redir_darwin.go | 64 + common/redir/redir_linux.go | 40 + common/redir/redir_other.go | 13 + common/redir/tproxy_linux.go | 59 + common/redir/tproxy_other.go | 22 + common/settings/proxy_android.go | 73 + common/settings/proxy_darwin.go | 121 + common/settings/proxy_linux.go | 176 + common/settings/proxy_stub.go | 14 + common/settings/proxy_windows.go | 43 + common/settings/system_proxy.go | 7 + common/settings/wifi.go | 9 + common/settings/wifi_linux.go | 46 + common/settings/wifi_linux_connman.go | 168 + common/settings/wifi_linux_iwd.go | 190 + common/settings/wifi_linux_nm.go | 165 + common/settings/wifi_linux_wpa.go | 225 + common/settings/wifi_stub.go | 27 + common/settings/wifi_windows.go | 144 + common/sniff/bittorrent.go | 107 + common/sniff/bittorrent_test.go | 110 + common/sniff/dns.go | 57 + common/sniff/dns_test.go | 53 + common/sniff/dtls.go | 32 + common/sniff/dtls_test.go | 33 + common/sniff/http.go | 28 + common/sniff/http_test.go | 30 + common/sniff/internal/qtls/qtls.go | 148 + common/sniff/ntp.go | 58 + common/sniff/ntp_test.go | 33 + common/sniff/quic.go | 373 ++ common/sniff/quic_blacklist.go | 29 + common/sniff/quic_capture_test.go | 188 + common/sniff/quic_test.go | 93 + common/sniff/rdp.go | 91 + common/sniff/rdp_test.go | 25 + common/sniff/sniff.go | 88 + common/sniff/ssh.go | 31 + common/sniff/ssh_test.go | 47 + common/sniff/stun.go | 25 + common/sniff/stun_test.go | 32 + common/sniff/tls.go | 33 + common/srs/binary.go | 704 +++ common/srs/compat_test.go | 494 ++ common/srs/ip_cidr.go | 44 + common/srs/ip_set.go | 98 + common/stun/stun.go | 612 +++ common/taskmonitor/monitor.go | 31 + common/tls/acme.go | 134 + common/tls/acme_logger.go | 41 + common/tls/acme_stub.go | 17 + common/tls/client.go | 139 + common/tls/common.go | 12 + common/tls/config.go | 37 + common/tls/ech.go | 208 + common/tls/ech_shared.go | 81 + common/tls/ech_tag_stub.go | 5 + common/tls/ktls.go | 67 + common/tls/mkcert.go | 65 + common/tls/reality_client.go | 332 ++ common/tls/reality_server.go | 239 + common/tls/reality_stub.go | 5 + common/tls/server.go | 62 + common/tls/std_client.go | 240 + common/tls/std_server.go | 528 ++ common/tls/time_wrapper.go | 25 + common/tls/utls_client.go | 332 ++ common/tls/utls_stub.go | 24 + common/tlsfragment/conn.go | 146 + common/tlsfragment/conn_test.go | 42 + common/tlsfragment/index.go | 133 + common/tlsfragment/index_test.go | 20 + common/tlsfragment/wait_darwin.go | 93 + common/tlsfragment/wait_linux.go | 40 + common/tlsfragment/wait_stub.go | 18 + common/tlsfragment/wait_windows.go | 31 + common/uot/router.go | 86 + common/urltest/urltest.go | 130 + constant/certificate.go | 8 + constant/cgo.go | 5 + constant/cgo_disabled.go | 5 + constant/dhcp.go | 8 + constant/dns.go | 36 + constant/err.go | 7 + constant/goos/gengoos.go | 68 + constant/goos/goos.go | 12 + constant/goos/zgoos_aix.go | 25 + constant/goos/zgoos_android.go | 25 + constant/goos/zgoos_darwin.go | 25 + constant/goos/zgoos_dragonfly.go | 25 + constant/goos/zgoos_freebsd.go | 25 + constant/goos/zgoos_hurd.go | 25 + constant/goos/zgoos_illumos.go | 25 + constant/goos/zgoos_ios.go | 25 + constant/goos/zgoos_js.go | 25 + constant/goos/zgoos_linux.go | 25 + constant/goos/zgoos_netbsd.go | 25 + constant/goos/zgoos_openbsd.go | 25 + constant/goos/zgoos_plan9.go | 25 + constant/goos/zgoos_solaris.go | 25 + constant/goos/zgoos_windows.go | 25 + constant/goos/zgoos_zos.go | 25 + constant/hysteria2.go | 7 + constant/network.go | 58 + constant/os.go | 37 + constant/path.go | 41 + constant/path_unix.go | 17 + constant/protocol.go | 22 + constant/proxy.go | 103 + constant/quic.go | 5 + constant/quic_stub.go | 5 + constant/rule.go | 48 + constant/speed.go | 3 + constant/time.go | 3 + constant/timeout.go | 35 + constant/tls.go | 3 + constant/v2ray.go | 9 + constant/version.go | 3 + daemon/deprecated.go | 29 + daemon/instance.go | 153 + daemon/platform.go | 10 + daemon/started_service.go | 1489 ++++++ daemon/started_service.pb.go | 3197 ++++++++++++ daemon/started_service.proto | 331 ++ daemon/started_service_grpc.pb.go | 1204 +++++ debug.go | 34 + debug_http.go | 76 + debug_stub.go | 7 + debug_unix.go | 25 + dns/client.go | 709 +++ dns/client_log.go | 82 + dns/client_truncate.go | 30 + dns/extension_edns0_subnet.go | 57 + dns/rcode.go | 19 + dns/repro_test.go | 111 + dns/router.go | 1149 +++++ dns/router_test.go | 2547 +++++++++ dns/transport/base.go | 145 + dns/transport/connector.go | 321 ++ dns/transport/connector_test.go | 407 ++ dns/transport/dhcp/dhcp.go | 310 ++ dns/transport/dhcp/dhcp_shared.go | 205 + dns/transport/fakeip/fakeip.go | 79 + dns/transport/fakeip/memory.go | 93 + dns/transport/fakeip/store.go | 161 + dns/transport/hosts/hosts.go | 87 + dns/transport/hosts/hosts_file.go | 102 + dns/transport/hosts/hosts_test.go | 16 + dns/transport/hosts/hosts_unix.go | 5 + dns/transport/hosts/hosts_windows.go | 17 + dns/transport/hosts/testdata/hosts | 2 + dns/transport/https.go | 224 + dns/transport/https_transport.go | 80 + dns/transport/local/local.go | 94 + dns/transport/local/local_darwin.go | 92 + dns/transport/local/local_darwin_cgo.go | 249 + dns/transport/local/local_darwin_dhcp.go | 16 + dns/transport/local/local_darwin_nodhcp.go | 15 + dns/transport/local/local_resolved.go | 13 + dns/transport/local/local_resolved_linux.go | 501 ++ dns/transport/local/local_resolved_stub.go | 18 + dns/transport/local/local_shared.go | 185 + dns/transport/local/resolv.go | 144 + dns/transport/local/resolv_default.go | 23 + dns/transport/local/resolv_test.go | 13 + dns/transport/local/resolv_unix.go | 156 + dns/transport/local/resolv_windows.go | 118 + dns/transport/quic/http3.go | 205 + dns/transport/quic/quic.go | 209 + dns/transport/tcp.go | 119 + dns/transport/tls.go | 154 + dns/transport/udp.go | 258 + dns/transport_adapter.go | 55 + dns/transport_dialer.go | 26 + dns/transport_manager.go | 300 ++ dns/transport_registry.go | 72 + docs/CNAME | 1 + docs/assets/icon.svg | 37 + docs/changelog.md | 4178 +++++++++++++++ docs/clients/android/features.md | 67 + docs/clients/android/index.md | 23 + docs/clients/apple/features.md | 55 + docs/clients/apple/index.md | 41 + docs/clients/general.md | 63 + docs/clients/index.md | 13 + docs/clients/index.zh.md | 12 + docs/clients/privacy.md | 18 + docs/configuration/certificate/index.md | 59 + docs/configuration/certificate/index.zh.md | 59 + docs/configuration/dns/fakeip.md | 31 + docs/configuration/dns/fakeip.zh.md | 31 + docs/configuration/dns/index.md | 133 + docs/configuration/dns/index.zh.md | 135 + docs/configuration/dns/rule.md | 699 +++ docs/configuration/dns/rule.zh.md | 694 +++ docs/configuration/dns/rule_action.md | 231 + docs/configuration/dns/rule_action.zh.md | 229 + docs/configuration/dns/server/dhcp.md | 38 + docs/configuration/dns/server/dhcp.zh.md | 38 + docs/configuration/dns/server/fakeip.md | 35 + docs/configuration/dns/server/fakeip.zh.md | 35 + docs/configuration/dns/server/hosts.md | 127 + docs/configuration/dns/server/hosts.zh.md | 127 + docs/configuration/dns/server/http3.md | 71 + docs/configuration/dns/server/http3.zh.md | 71 + docs/configuration/dns/server/https.md | 71 + docs/configuration/dns/server/https.zh.md | 71 + docs/configuration/dns/server/index.md | 48 + docs/configuration/dns/server/index.zh.md | 48 + docs/configuration/dns/server/legacy.md | 113 + docs/configuration/dns/server/legacy.zh.md | 113 + docs/configuration/dns/server/local.md | 61 + docs/configuration/dns/server/local.zh.md | 61 + docs/configuration/dns/server/quic.md | 58 + docs/configuration/dns/server/quic.zh.md | 58 + docs/configuration/dns/server/resolved.md | 117 + docs/configuration/dns/server/resolved.zh.md | 116 + docs/configuration/dns/server/tailscale.md | 116 + docs/configuration/dns/server/tailscale.zh.md | 116 + docs/configuration/dns/server/tcp.md | 52 + docs/configuration/dns/server/tcp.zh.md | 52 + docs/configuration/dns/server/tls.md | 58 + docs/configuration/dns/server/tls.zh.md | 58 + docs/configuration/dns/server/udp.md | 52 + docs/configuration/dns/server/udp.zh.md | 52 + docs/configuration/endpoint/index.md | 29 + docs/configuration/endpoint/index.zh.md | 29 + docs/configuration/endpoint/tailscale.md | 157 + docs/configuration/endpoint/tailscale.zh.md | 156 + docs/configuration/endpoint/wireguard.md | 129 + docs/configuration/endpoint/wireguard.zh.md | 131 + docs/configuration/experimental/cache-file.md | 70 + .../experimental/cache-file.zh.md | 67 + docs/configuration/experimental/clash-api.md | 166 + .../experimental/clash-api.zh.md | 164 + docs/configuration/experimental/index.md | 26 + docs/configuration/experimental/index.zh.md | 26 + docs/configuration/experimental/v2ray-api.md | 50 + .../experimental/v2ray-api.zh.md | 50 + docs/configuration/inbound/anytls.md | 61 + docs/configuration/inbound/anytls.zh.md | 61 + docs/configuration/inbound/cloudflared.md | 89 + docs/configuration/inbound/cloudflared.zh.md | 89 + docs/configuration/inbound/direct.md | 36 + docs/configuration/inbound/direct.zh.md | 37 + docs/configuration/inbound/http.md | 47 + docs/configuration/inbound/http.zh.md | 47 + docs/configuration/inbound/hysteria.md | 107 + docs/configuration/inbound/hysteria.zh.md | 107 + docs/configuration/inbound/hysteria2.md | 159 + docs/configuration/inbound/hysteria2.zh.md | 156 + docs/configuration/inbound/index.md | 41 + docs/configuration/inbound/index.zh.md | 41 + docs/configuration/inbound/mixed.md | 44 + docs/configuration/inbound/mixed.zh.md | 44 + docs/configuration/inbound/naive.md | 63 + docs/configuration/inbound/naive.zh.md | 63 + docs/configuration/inbound/redirect.md | 18 + docs/configuration/inbound/redirect.zh.md | 17 + docs/configuration/inbound/shadowsocks.md | 96 + docs/configuration/inbound/shadowsocks.zh.md | 96 + docs/configuration/inbound/shadowtls.md | 107 + docs/configuration/inbound/shadowtls.zh.md | 107 + docs/configuration/inbound/socks.md | 31 + docs/configuration/inbound/socks.zh.md | 31 + docs/configuration/inbound/tproxy.md | 28 + docs/configuration/inbound/tproxy.zh.md | 28 + docs/configuration/inbound/trojan.md | 68 + docs/configuration/inbound/trojan.zh.md | 68 + docs/configuration/inbound/tuic.md | 78 + docs/configuration/inbound/tuic.zh.md | 78 + docs/configuration/inbound/tun.md | 635 +++ docs/configuration/inbound/tun.zh.md | 623 +++ docs/configuration/inbound/vless.md | 59 + docs/configuration/inbound/vless.zh.md | 59 + docs/configuration/inbound/vmess.md | 54 + docs/configuration/inbound/vmess.zh.md | 54 + docs/configuration/index.md | 54 + docs/configuration/index.zh.md | 54 + docs/configuration/log/index.md | 33 + docs/configuration/log/index.zh.md | 33 + docs/configuration/ntp/index.md | 50 + docs/configuration/ntp/index.zh.md | 49 + docs/configuration/outbound/anytls.md | 66 + docs/configuration/outbound/anytls.zh.md | 66 + docs/configuration/outbound/block.md | 16 + docs/configuration/outbound/block.zh.md | 18 + docs/configuration/outbound/direct.md | 48 + docs/configuration/outbound/direct.zh.md | 46 + docs/configuration/outbound/dns.md | 26 + docs/configuration/outbound/dns.zh.md | 26 + docs/configuration/outbound/http.md | 58 + docs/configuration/outbound/http.zh.md | 58 + docs/configuration/outbound/hysteria.md | 141 + docs/configuration/outbound/hysteria.zh.md | 142 + docs/configuration/outbound/hysteria2.md | 141 + docs/configuration/outbound/hysteria2.zh.md | 139 + docs/configuration/outbound/index.md | 49 + docs/configuration/outbound/index.zh.md | 49 + docs/configuration/outbound/naive.md | 116 + docs/configuration/outbound/naive.zh.md | 116 + docs/configuration/outbound/selector.md | 38 + docs/configuration/outbound/selector.zh.md | 38 + docs/configuration/outbound/shadowsocks.md | 102 + docs/configuration/outbound/shadowsocks.zh.md | 102 + docs/configuration/outbound/shadowtls.md | 56 + docs/configuration/outbound/shadowtls.zh.md | 56 + docs/configuration/outbound/socks.md | 66 + docs/configuration/outbound/socks.zh.md | 66 + docs/configuration/outbound/ssh.md | 71 + docs/configuration/outbound/ssh.zh.md | 71 + docs/configuration/outbound/tor.md | 51 + docs/configuration/outbound/tor.zh.md | 51 + docs/configuration/outbound/trojan.md | 62 + docs/configuration/outbound/trojan.zh.md | 62 + docs/configuration/outbound/tuic.md | 96 + docs/configuration/outbound/tuic.zh.md | 104 + docs/configuration/outbound/urltest.md | 49 + docs/configuration/outbound/urltest.zh.md | 49 + docs/configuration/outbound/vless.md | 82 + docs/configuration/outbound/vless.zh.md | 82 + docs/configuration/outbound/vmess.md | 107 + docs/configuration/outbound/vmess.zh.md | 107 + docs/configuration/outbound/wireguard.md | 168 + docs/configuration/outbound/wireguard.zh.md | 142 + docs/configuration/route/geoip.md | 41 + docs/configuration/route/geoip.zh.md | 41 + docs/configuration/route/geosite.md | 41 + docs/configuration/route/geosite.zh.md | 41 + docs/configuration/route/index.md | 186 + docs/configuration/route/index.zh.md | 185 + docs/configuration/route/rule.md | 547 ++ docs/configuration/route/rule.zh.md | 545 ++ docs/configuration/route/rule_action.md | 330 ++ docs/configuration/route/rule_action.zh.md | 319 ++ docs/configuration/route/sniff.md | 32 + docs/configuration/route/sniff.zh.md | 32 + docs/configuration/rule-set/adguard.md | 65 + docs/configuration/rule-set/adguard.zh.md | 64 + docs/configuration/rule-set/headless-rule.md | 325 ++ .../rule-set/headless-rule.zh.md | 316 ++ docs/configuration/rule-set/index.md | 115 + docs/configuration/rule-set/index.zh.md | 115 + docs/configuration/rule-set/source-format.md | 54 + .../rule-set/source-format.zh.md | 54 + docs/configuration/service/ccm.md | 131 + docs/configuration/service/ccm.zh.md | 131 + docs/configuration/service/derp.md | 135 + docs/configuration/service/derp.zh.md | 135 + docs/configuration/service/index.md | 34 + docs/configuration/service/index.zh.md | 34 + docs/configuration/service/ocm.md | 185 + docs/configuration/service/ocm.zh.md | 186 + docs/configuration/service/resolved.md | 44 + docs/configuration/service/resolved.zh.md | 44 + docs/configuration/service/ssm-api.md | 58 + docs/configuration/service/ssm-api.zh.md | 58 + .../shared/certificate-provider/acme.md | 150 + .../shared/certificate-provider/acme.zh.md | 145 + .../cloudflare-origin-ca.md | 82 + .../cloudflare-origin-ca.zh.md | 82 + .../shared/certificate-provider/index.md | 32 + .../shared/certificate-provider/index.zh.md | 32 + .../shared/certificate-provider/tailscale.md | 27 + .../certificate-provider/tailscale.zh.md | 27 + docs/configuration/shared/dial.md | 270 + docs/configuration/shared/dial.zh.md | 258 + docs/configuration/shared/dns01_challenge.md | 126 + .../shared/dns01_challenge.zh.md | 126 + docs/configuration/shared/listen.md | 202 + docs/configuration/shared/listen.zh.md | 200 + docs/configuration/shared/multiplex.md | 86 + docs/configuration/shared/multiplex.zh.md | 85 + docs/configuration/shared/neighbor.md | 49 + docs/configuration/shared/neighbor.zh.md | 49 + docs/configuration/shared/pre-match.md | 50 + docs/configuration/shared/pre-match.zh.md | 47 + docs/configuration/shared/tcp-brutal.md | 28 + docs/configuration/shared/tcp-brutal.zh.md | 28 + docs/configuration/shared/tls.md | 705 +++ docs/configuration/shared/tls.zh.md | 695 +++ docs/configuration/shared/udp-over-tcp.md | 82 + docs/configuration/shared/udp-over-tcp.zh.md | 82 + docs/configuration/shared/v2ray-transport.md | 229 + .../shared/v2ray-transport.zh.md | 218 + docs/configuration/shared/wifi-state.md | 41 + docs/configuration/shared/wifi-state.zh.md | 41 + docs/deprecated.md | 177 + docs/deprecated.zh.md | 168 + docs/index.md | 31 + docs/index.zh.md | 31 + docs/installation/build-from-source.md | 126 + docs/installation/build-from-source.zh.md | 130 + docs/installation/docker.md | 31 + docs/installation/docker.zh.md | 31 + docs/installation/package-manager.md | 157 + docs/installation/package-manager.zh.md | 150 + docs/installation/tools/arch-install.sh | 22 + docs/installation/tools/deb-install.sh | 23 + docs/installation/tools/install.sh | 127 + docs/installation/tools/rpm-install.sh | 22 + docs/installation/tools/sing-box.repo | 7 + docs/manual/misc/tunnelvision.md | 38 + docs/manual/proxy-protocol/hysteria2.md | 212 + docs/manual/proxy-protocol/shadowsocks.md | 125 + docs/manual/proxy-protocol/trojan.md | 200 + docs/manual/proxy/client.md | 503 ++ docs/manual/proxy/server.md | 10 + docs/migration.md | 1438 ++++++ docs/migration.zh.md | 1427 +++++ docs/sponsors.md | 32 + docs/support.md | 12 + docs/support.zh.md | 13 + experimental/cachefile/cache.go | 417 ++ experimental/cachefile/dns_cache.go | 299 ++ experimental/cachefile/fakeip.go | 194 + experimental/cachefile/rdrc.go | 109 + experimental/clashapi.go | 81 + experimental/clashapi/api_meta.go | 96 + experimental/clashapi/api_meta_group.go | 136 + experimental/clashapi/api_meta_upgrade.go | 36 + experimental/clashapi/cache.go | 44 + experimental/clashapi/common.go | 17 + experimental/clashapi/configs.go | 71 + experimental/clashapi/connections.go | 108 + experimental/clashapi/ctxkeys.go | 14 + experimental/clashapi/dns.go | 82 + experimental/clashapi/errors.go | 22 + experimental/clashapi/profile.go | 53 + experimental/clashapi/provider.go | 74 + experimental/clashapi/proxies.go | 234 + experimental/clashapi/ruleprovider.go | 58 + experimental/clashapi/rules.go | 40 + experimental/clashapi/script.go | 98 + experimental/clashapi/server.go | 435 ++ experimental/clashapi/server_fs.go | 18 + experimental/clashapi/server_resources.go | 170 + .../clashapi/trafficontrol/manager.go | 181 + .../clashapi/trafficontrol/tracker.go | 254 + experimental/deprecated/constants.go | 151 + experimental/deprecated/manager.go | 19 + experimental/deprecated/stderr.go | 42 + experimental/libbox/build_info.go | 234 + experimental/libbox/command.go | 10 + experimental/libbox/command_client.go | 807 +++ experimental/libbox/command_server.go | 288 ++ experimental/libbox/command_types.go | 446 ++ experimental/libbox/command_types_nq.go | 51 + experimental/libbox/command_types_stun.go | 35 + .../libbox/command_types_tailscale.go | 132 + .../libbox/command_types_tailscale_ping.go | 28 + experimental/libbox/config.go | 222 + .../libbox/connection_owner_darwin.go | 57 + experimental/libbox/debug.go | 12 + experimental/libbox/deprecated.go | 33 + experimental/libbox/dns.go | 150 + experimental/libbox/fdroid.go | 493 ++ experimental/libbox/fdroid_mirrors.go | 92 + experimental/libbox/ffi.json | 257 + experimental/libbox/http.go | 274 + .../libbox/internal/oomprofile/builder.go | 390 ++ .../internal/oomprofile/defs_darwin_amd64.go | 24 + .../internal/oomprofile/defs_darwin_arm64.go | 24 + .../libbox/internal/oomprofile/linkname.go | 46 + .../internal/oomprofile/mapping_darwin.go | 56 + .../internal/oomprofile/mapping_linux.go | 13 + .../internal/oomprofile/mapping_windows.go | 58 + .../libbox/internal/oomprofile/oomprofile.go | 383 ++ .../libbox/internal/oomprofile/protobuf.go | 120 + experimental/libbox/internal/procfs/procfs.go | 148 + experimental/libbox/iterator.go | 63 + experimental/libbox/link_flags_stub.go | 11 + experimental/libbox/link_flags_unix.go | 32 + experimental/libbox/log.go | 165 + experimental/libbox/monitor.go | 117 + experimental/libbox/neighbor.go | 53 + experimental/libbox/neighbor_darwin.go | 123 + experimental/libbox/neighbor_linux.go | 88 + experimental/libbox/neighbor_stub.go | 9 + experimental/libbox/networkquality.go | 74 + experimental/libbox/oom_report.go | 141 + experimental/libbox/panic.go | 12 + experimental/libbox/pidfd_android.go | 19 + experimental/libbox/platform.go | 141 + experimental/libbox/pprof.go | 33 + experimental/libbox/profile_import.go | 278 + experimental/libbox/remote_profile.go | 41 + experimental/libbox/report.go | 97 + experimental/libbox/semver.go | 27 + experimental/libbox/semver_test.go | 16 + experimental/libbox/service.go | 288 ++ experimental/libbox/service_other.go | 9 + experimental/libbox/service_windows.go | 7 + experimental/libbox/setup.go | 191 + experimental/libbox/signal_handler_darwin.go | 146 + experimental/libbox/signal_handler_stub.go | 7 + experimental/libbox/stun.go | 50 + experimental/libbox/tun.go | 168 + experimental/libbox/tun_darwin.go | 34 + experimental/libbox/tun_name_darwin.go | 11 + experimental/libbox/tun_name_linux.go | 26 + experimental/libbox/tun_name_other.go | 9 + experimental/locale/locale.go | 32 + experimental/locale/locale_zh_CN.go | 11 + experimental/v2rayapi.go | 24 + experimental/v2rayapi/server.go | 82 + experimental/v2rayapi/stats.go | 222 + experimental/v2rayapi/stats.pb.go | 538 ++ experimental/v2rayapi/stats.proto | 53 + experimental/v2rayapi/stats_grpc.pb.go | 194 + go.mod | 173 + go.sum | 415 ++ include/acme.go | 12 + include/acme_stub.go | 20 + include/ccm.go | 12 + include/ccm_stub.go | 20 + include/ccm_stub_darwin.go | 20 + include/clashapi.go | 5 + include/clashapi_stub.go | 19 + include/cloudflared.go | 12 + include/cloudflared_stub.go | 20 + include/dhcp.go | 12 + include/dhcp_stub.go | 20 + include/naive_outbound.go | 12 + include/naive_outbound_stub.go | 20 + include/ocm.go | 12 + include/ocm_stub.go | 20 + include/oom_killer.go | 10 + include/quic.go | 32 + include/quic_stub.go | 71 + include/registry.go | 168 + include/tailscale.go | 28 + include/tailscale_stub.go | 41 + include/tz_android.go | 21 + include/tz_ios.go | 30 + include/v2rayapi.go | 5 + include/v2rayapi_stub.go | 17 + include/wireguard.go | 12 + include/wireguard_stub.go | 20 + log/export.go | 84 + log/factory.go | 30 + log/format.go | 174 + log/id.go | 36 + log/level.go | 59 + log/log.go | 71 + log/nop.go | 88 + log/observable.go | 199 + log/override.go | 19 + log/platform.go | 5 + mkdocs.yml | 295 ++ option/acme.go | 106 + option/anytls.go | 25 + option/ccm.go | 20 + option/certificate.go | 36 + option/certificate_provider.go | 100 + option/cloudflared.go | 16 + option/debug.go | 14 + option/direct.go | 39 + option/dns.go | 184 + option/dns_record.go | 125 + option/dns_record_test.go | 40 + option/dns_test.go | 54 + option/endpoint.go | 47 + option/experimental.go | 55 + option/group.go | 18 + option/hysteria.go | 46 + option/hysteria2.go | 127 + option/inbound.go | 117 + option/multiplex.go | 23 + option/naive.go | 40 + option/ntp.go | 11 + option/ocm.go | 20 + option/oom_killer.go | 15 + option/options.go | 120 + option/origin_ca.go | 76 + option/outbound.go | 169 + option/platform.go | 105 + option/redir.go | 10 + option/resolved.go | 49 + option/route.go | 35 + option/rule.go | 223 + option/rule_action.go | 339 ++ option/rule_action_test.go | 29 + option/rule_dns.go | 208 + option/rule_nested.go | 133 + option/rule_nested_test.go | 68 + option/rule_set.go | 288 ++ option/service.go | 47 + option/shadowsocks.go | 35 + option/shadowsocksr.go | 13 + option/shadowtls.go | 81 + option/simple.go | 40 + option/ssh.go | 16 + option/ssmapi.go | 12 + option/tailscale.go | 126 + option/tls.go | 242 + option/tls_acme.go | 96 + option/tor.go | 9 + option/trojan.go | 26 + option/tuic.go | 33 + option/tun.go | 88 + option/tun_platform.go | 14 + option/types.go | 196 + option/udp_over_tcp.go | 30 + option/v2ray.go | 1 + option/v2ray_transport.go | 100 + option/vless.go | 27 + option/vmess.go | 30 + option/wireguard.go | 30 + protocol/anytls/inbound.go | 134 + protocol/anytls/outbound.go | 131 + protocol/block/outbound.go | 42 + protocol/cloudflare/inbound.go | 160 + protocol/direct/inbound.go | 144 + protocol/direct/loopback_detect.go | 186 + protocol/direct/outbound.go | 178 + protocol/dns/handle.go | 220 + protocol/dns/outbound.go | 63 + protocol/group/selector.go | 192 + protocol/group/urltest.go | 429 ++ protocol/http/inbound.go | 132 + protocol/http/outbound.go | 65 + protocol/hysteria/inbound.go | 180 + protocol/hysteria/outbound.go | 126 + protocol/hysteria2/inbound.go | 214 + protocol/hysteria2/outbound.go | 122 + protocol/mixed/inbound.go | 168 + protocol/naive/inbound.go | 251 + protocol/naive/inbound_conn.go | 257 + protocol/naive/outbound.go | 275 + protocol/naive/quic/inbound_init.go | 128 + protocol/redirect/redirect.go | 68 + protocol/redirect/tproxy.go | 150 + protocol/shadowsocks/inbound.go | 188 + protocol/shadowsocks/inbound_multi.go | 212 + protocol/shadowsocks/inbound_relay.go | 166 + protocol/shadowsocks/outbound.go | 178 + protocol/shadowtls/inbound.go | 141 + protocol/shadowtls/outbound.go | 104 + protocol/socks/inbound.go | 119 + protocol/socks/outbound.go | 117 + protocol/ssh/outbound.go | 219 + protocol/tailscale/certificate_provider.go | 98 + protocol/tailscale/dns_transport.go | 311 ++ protocol/tailscale/endpoint.go | 860 +++ protocol/tailscale/hostinfo_tvos.go | 16 + protocol/tailscale/ping.go | 55 + protocol/tailscale/status.go | 105 + protocol/tailscale/tun_device_unix.go | 156 + protocol/tailscale/tun_device_windows.go | 117 + protocol/tor/outbound.go | 212 + protocol/tor/proxy.go | 121 + protocol/trojan/inbound.go | 262 + protocol/trojan/outbound.go | 158 + protocol/tuic/inbound.go | 170 + protocol/tuic/outbound.go | 141 + protocol/tun/hook.go | 3 + protocol/tun/inbound.go | 559 ++ protocol/vless/inbound.go | 222 + protocol/vless/outbound.go | 218 + protocol/vmess/inbound.go | 228 + protocol/vmess/outbound.go | 206 + protocol/wireguard/endpoint.go | 265 + release/DEFAULT_BUILD_TAGS | 1 + release/DEFAULT_BUILD_TAGS_OTHERS | 1 + release/DEFAULT_BUILD_TAGS_WINDOWS | 1 + release/LDFLAGS | 1 + release/completions/sing-box.bash | 1580 ++++++ release/completions/sing-box.fish | 235 + release/completions/sing-box.zsh | 212 + release/config/config.json | 41 + release/config/openwrt.conf | 5 + release/config/openwrt.init | 32 + release/config/openwrt.keep | 1 + release/config/openwrt.prerm | 4 + release/config/sing-box-split-dns.xml | 15 + release/config/sing-box.confd | 6 + release/config/sing-box.initd | 44 + release/config/sing-box.postinst | 3 + release/config/sing-box.rules | 8 + release/config/sing-box.service | 18 + release/config/sing-box.sysusers | 1 + release/config/sing-box@.service | 18 + release/local/common.sh | 102 + release/local/debug.sh | 26 + release/local/enable.sh | 7 + release/local/install.sh | 19 + release/local/install_go.sh | 9 + release/local/reinstall.sh | 19 + release/local/sing-box.service | 16 + release/local/uninstall.sh | 30 + release/local/update.sh | 16 + route/conn.go | 423 ++ route/dns.go | 109 + route/neighbor_resolver_darwin.go | 239 + route/neighbor_resolver_lease.go | 386 ++ route/neighbor_resolver_linux.go | 224 + route/neighbor_resolver_parse.go | 50 + route/neighbor_resolver_platform.go | 84 + route/neighbor_resolver_stub.go | 14 + route/neighbor_table_darwin.go | 104 + route/neighbor_table_linux.go | 68 + route/network.go | 542 ++ route/platform_searcher.go | 49 + route/process_cache.go | 34 + route/route.go | 833 +++ route/router.go | 284 + route/rule/match_state.go | 126 + route/rule/rule_abstract.go | 302 ++ route/rule/rule_abstract_test.go | 157 + route/rule/rule_action.go | 610 +++ route/rule/rule_default.go | 348 ++ route/rule/rule_default_interface_address.go | 56 + route/rule/rule_dns.go | 525 ++ route/rule/rule_headless.go | 246 + route/rule/rule_interface_address.go | 62 + route/rule/rule_item_adguard.go | 43 + route/rule/rule_item_auth_user.go | 37 + route/rule/rule_item_cidr.go | 110 + route/rule/rule_item_clash_mode.go | 40 + route/rule/rule_item_client.go | 37 + route/rule/rule_item_domain.go | 79 + route/rule/rule_item_domain_keyword.go | 47 + route/rule/rule_item_domain_regex.go | 61 + route/rule/rule_item_inbound.go | 35 + route/rule/rule_item_ip_accept_any.go | 24 + route/rule/rule_item_ip_is_private.go | 47 + route/rule/rule_item_ipversion.go | 30 + route/rule/rule_item_network.go | 42 + .../rule/rule_item_network_is_constrained.go | 29 + route/rule/rule_item_network_is_expensive.go | 29 + route/rule/rule_item_network_type.go | 40 + route/rule/rule_item_outbound.go | 46 + route/rule/rule_item_package_name.go | 48 + route/rule/rule_item_package_name_regex.go | 56 + route/rule/rule_item_port.go | 52 + route/rule/rule_item_port_range.go | 87 + route/rule/rule_item_preferred_by.go | 86 + route/rule/rule_item_process_name.go | 44 + route/rule/rule_item_process_path.go | 54 + route/rule/rule_item_process_path_regex.go | 54 + route/rule/rule_item_protocol.go | 37 + route/rule/rule_item_query_type.go | 47 + route/rule/rule_item_response_rcode.go | 26 + route/rule/rule_item_response_record.go | 63 + route/rule/rule_item_rule_set.go | 89 + route/rule/rule_item_rule_set_test.go | 138 + route/rule/rule_item_source_hostname.go | 42 + route/rule/rule_item_source_mac_address.go | 48 + route/rule/rule_item_user.go | 40 + route/rule/rule_item_user_id.go | 44 + route/rule/rule_item_wifi_bssid.go | 39 + route/rule/rule_item_wifi_ssid.go | 39 + route/rule/rule_nested_action.go | 71 + route/rule/rule_nested_action_test.go | 88 + route/rule/rule_network_interface_address.go | 64 + route/rule/rule_set.go | 93 + route/rule/rule_set_local.go | 221 + route/rule/rule_set_remote.go | 343 ++ route/rule/rule_set_semantics_test.go | 1261 +++++ route/rule/rule_set_update_validation_test.go | 111 + route/rule_conds.go | 62 + service/acme/service.go | 411 ++ service/acme/stub.go | 3 + service/ccm/credential.go | 139 + service/ccm/credential_darwin.go | 116 + service/ccm/credential_other.go | 25 + service/ccm/service.go | 597 +++ service/ccm/service_usage.go | 706 +++ service/ccm/service_user.go | 29 + service/derp/service.go | 529 ++ service/ocm/credential.go | 173 + service/ocm/credential_darwin.go | 25 + service/ocm/credential_other.go | 25 + service/ocm/service.go | 707 +++ service/ocm/service_usage.go | 1202 +++++ service/ocm/service_user.go | 29 + service/ocm/service_websocket.go | 285 + service/oomkiller/policy.go | 46 + service/oomkiller/service.go | 83 + service/oomkiller/service_darwin.go | 105 + service/oomkiller/service_stub.go | 24 + service/oomkiller/timer.go | 338 ++ service/origin_ca/service.go | 618 +++ service/resolved/resolve1.go | 656 +++ service/resolved/service.go | 254 + service/resolved/stub.go | 27 + service/resolved/transport.go | 307 ++ service/ssmapi/api.go | 177 + service/ssmapi/cache.go | 239 + service/ssmapi/server.go | 163 + service/ssmapi/traffic.go | 223 + service/ssmapi/user.go | 87 + test/box_test.go | 182 + test/brutal_test.go | 392 ++ test/clash_darwin_test.go | 71 + test/clash_other_test.go | 12 + test/clash_test.go | 524 ++ test/config/hysteria-client.json | 12 + test/config/hysteria-server.json | 9 + test/config/hysteria2-client.yml | 11 + test/config/hysteria2-server.yml | 12 + test/config/naive-nginx.conf | 18 + test/config/naive-quic.json | 6 + test/config/naive.json | 6 + test/config/nginx.conf | 29 + test/config/shadowsocksr.json | 19 + test/config/trojan.json | 40 + test/config/tuic-client.json | 16 + test/config/tuic-server.json | 10 + test/config/vless-server.json | 25 + test/config/vless-tls-client.json | 51 + test/config/vless-tls-server.json | 39 + test/config/vmess-client.json | 38 + test/config/vmess-grpc-client.json | 51 + test/config/vmess-grpc-server.json | 40 + test/config/vmess-mux-client.json | 41 + test/config/vmess-server.json | 25 + test/config/vmess-ws-client.json | 52 + test/config/vmess-ws-server.json | 41 + test/config/wireguard.conf | 8 + test/direct_test.go | 73 + test/docker_test.go | 121 + test/domain_inbound_test.go | 94 + test/ech_test.go | 287 + test/go.mod | 179 + test/go.sum | 429 ++ test/http_test.go | 72 + test/hysteria2_test.go | 234 + test/hysteria_test.go | 189 + test/inbound_detour_test.go | 103 + test/ktls_test.go | 295 ++ test/mkcert.go | 88 + test/mux_cool_test.go | 182 + test/mux_test.go | 198 + test/naive_self_test.go | 533 ++ test/naive_test.go | 147 + test/reality_test.go | 108 + test/shadowsocks_legacy_test.go | 55 + test/shadowsocks_test.go | 367 ++ test/shadowtls_test.go | 498 ++ test/socks_test.go | 133 + test/ss_plugin_test.go | 66 + test/tfo_test.go | 83 + test/tls_test.go | 99 + test/trojan_test.go | 209 + test/tuic_test.go | 198 + test/v2ray_api_test.go | 60 + test/v2ray_grpc_test.go | 219 + test/v2ray_httpupgrade_test.go | 16 + test/v2ray_transport_test.go | 384 ++ test/v2ray_ws_test.go | 205 + test/vmess_test.go | 337 ++ test/wrapper_test.go | 30 + transport/simple-obfs/README.md | 4 + transport/simple-obfs/http.go | 98 + transport/simple-obfs/tls.go | 205 + transport/sip003/args.go | 119 + transport/sip003/obfs.go | 62 + transport/sip003/plugin.go | 38 + transport/sip003/v2ray.go | 119 + transport/trojan/mux.go | 84 + transport/trojan/protocol.go | 321 ++ transport/trojan/protocol_wait.go | 45 + transport/trojan/service.go | 149 + transport/trojan/service_wait.go | 45 + transport/v2ray/grpc.go | 30 + transport/v2ray/grpc_lite.go | 23 + transport/v2ray/quic.go | 37 + transport/v2ray/transport.go | 68 + transport/v2raygrpc/client.go | 118 + transport/v2raygrpc/conn.go | 106 + .../v2raygrpc/credentials/credentials.go | 49 + transport/v2raygrpc/credentials/spiffe.go | 75 + .../v2raygrpc/credentials/syscallconn.go | 58 + transport/v2raygrpc/credentials/util.go | 52 + transport/v2raygrpc/custom_name.go | 51 + transport/v2raygrpc/server.go | 97 + transport/v2raygrpc/stream.pb.go | 125 + transport/v2raygrpc/stream.proto | 12 + transport/v2raygrpc/stream_grpc.pb.go | 110 + transport/v2raygrpc/tls_credentials.go | 86 + transport/v2raygrpclite/client.go | 108 + transport/v2raygrpclite/conn.go | 169 + transport/v2raygrpclite/server.go | 119 + transport/v2rayhttp/client.go | 157 + transport/v2rayhttp/conn.go | 267 + transport/v2rayhttp/force_close.go | 47 + transport/v2rayhttp/pool.go | 13 + transport/v2rayhttp/server.go | 183 + transport/v2rayhttpupgrade/client.go | 115 + transport/v2rayhttpupgrade/server.go | 145 + transport/v2rayquic/client.go | 110 + transport/v2rayquic/init.go | 9 + transport/v2rayquic/server.go | 99 + transport/v2rayquic/stream.go | 41 + transport/v2raywebsocket/client.go | 123 + transport/v2raywebsocket/conn.go | 306 ++ transport/v2raywebsocket/server.go | 145 + transport/v2raywebsocket/writer.go | 74 + transport/wireguard/client_bind.go | 262 + transport/wireguard/device.go | 51 + transport/wireguard/device_nat.go | 103 + transport/wireguard/device_stack.go | 342 ++ transport/wireguard/device_stack_gonet.go | 79 + transport/wireguard/device_stack_stub.go | 13 + transport/wireguard/device_system.go | 189 + transport/wireguard/device_system_stack.go | 243 + transport/wireguard/endpoint.go | 290 ++ transport/wireguard/endpoint_options.go | 39 + 1086 files changed, 147554 insertions(+) create mode 100644 .fpm_openwrt create mode 100644 .fpm_pacman create mode 100644 .fpm_systemd create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .golangci.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.binary create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 adapter/certificate.go create mode 100644 adapter/certificate/adapter.go create mode 100644 adapter/certificate/manager.go create mode 100644 adapter/certificate/registry.go create mode 100644 adapter/certificate_provider.go create mode 100644 adapter/connections.go create mode 100644 adapter/dns.go create mode 100644 adapter/endpoint.go create mode 100644 adapter/endpoint/adapter.go create mode 100644 adapter/endpoint/manager.go create mode 100644 adapter/endpoint/registry.go create mode 100644 adapter/experimental.go create mode 100644 adapter/fakeip.go create mode 100644 adapter/fakeip_metadata.go create mode 100644 adapter/handler.go create mode 100644 adapter/inbound.go create mode 100644 adapter/inbound/adapter.go create mode 100644 adapter/inbound/manager.go create mode 100644 adapter/inbound/registry.go create mode 100644 adapter/inbound_test.go create mode 100644 adapter/lifecycle.go create mode 100644 adapter/lifecycle_legacy.go create mode 100644 adapter/neighbor.go create mode 100644 adapter/network.go create mode 100644 adapter/outbound.go create mode 100644 adapter/outbound/adapter.go create mode 100644 adapter/outbound/manager.go create mode 100644 adapter/outbound/registry.go create mode 100644 adapter/platform.go create mode 100644 adapter/prestart.go create mode 100644 adapter/router.go create mode 100644 adapter/rule.go create mode 100644 adapter/service.go create mode 100644 adapter/service/adapter.go create mode 100644 adapter/service/manager.go create mode 100644 adapter/service/registry.go create mode 100644 adapter/ssm.go create mode 100644 adapter/tailscale.go create mode 100644 adapter/time.go create mode 100644 adapter/upstream.go create mode 100644 adapter/upstream_legacy.go create mode 100644 adapter/v2ray.go create mode 100644 box.go create mode 100644 cmd/internal/app_store_connect/main.go create mode 100644 cmd/internal/build/main.go create mode 100644 cmd/internal/build_libbox/main.go create mode 100644 cmd/internal/build_shared/sdk.go create mode 100644 cmd/internal/build_shared/tag.go create mode 100644 cmd/internal/format_docs/main.go create mode 100644 cmd/internal/protogen/main.go create mode 100644 cmd/internal/read_tag/main.go create mode 100644 cmd/internal/tun_bench/main.go create mode 100644 cmd/internal/update_android_version/main.go create mode 100644 cmd/internal/update_apple_version/main.go create mode 100644 cmd/internal/update_certificates/main.go create mode 100644 cmd/sing-box/cmd.go create mode 100644 cmd/sing-box/cmd_check.go create mode 100644 cmd/sing-box/cmd_format.go create mode 100644 cmd/sing-box/cmd_generate.go create mode 100644 cmd/sing-box/cmd_generate_ech.go create mode 100644 cmd/sing-box/cmd_generate_tls.go create mode 100644 cmd/sing-box/cmd_generate_vapid.go create mode 100644 cmd/sing-box/cmd_generate_wireguard.go create mode 100644 cmd/sing-box/cmd_geoip.go create mode 100644 cmd/sing-box/cmd_geoip_export.go create mode 100644 cmd/sing-box/cmd_geoip_list.go create mode 100644 cmd/sing-box/cmd_geoip_lookup.go create mode 100644 cmd/sing-box/cmd_geosite.go create mode 100644 cmd/sing-box/cmd_geosite_export.go create mode 100644 cmd/sing-box/cmd_geosite_list.go create mode 100644 cmd/sing-box/cmd_geosite_lookup.go create mode 100644 cmd/sing-box/cmd_geosite_matcher.go create mode 100644 cmd/sing-box/cmd_merge.go create mode 100644 cmd/sing-box/cmd_rule_set.go create mode 100644 cmd/sing-box/cmd_rule_set_compile.go create mode 100644 cmd/sing-box/cmd_rule_set_convert.go create mode 100644 cmd/sing-box/cmd_rule_set_decompile.go create mode 100644 cmd/sing-box/cmd_rule_set_format.go create mode 100644 cmd/sing-box/cmd_rule_set_match.go create mode 100644 cmd/sing-box/cmd_rule_set_merge.go create mode 100644 cmd/sing-box/cmd_rule_set_upgrade.go create mode 100644 cmd/sing-box/cmd_run.go create mode 100644 cmd/sing-box/cmd_tools.go create mode 100644 cmd/sing-box/cmd_tools_connect.go create mode 100644 cmd/sing-box/cmd_tools_fetch.go create mode 100644 cmd/sing-box/cmd_tools_fetch_http3.go create mode 100644 cmd/sing-box/cmd_tools_fetch_http3_stub.go create mode 100644 cmd/sing-box/cmd_tools_networkquality.go create mode 100644 cmd/sing-box/cmd_tools_stun.go create mode 100644 cmd/sing-box/cmd_tools_synctime.go create mode 100644 cmd/sing-box/cmd_version.go create mode 100644 cmd/sing-box/generate_completions.go create mode 100644 cmd/sing-box/main.go create mode 100644 common/badtls/raw_conn.go create mode 100644 common/badtls/raw_half_conn.go create mode 100644 common/badtls/read_wait.go create mode 100644 common/badtls/read_wait_stub.go create mode 100644 common/badtls/registry.go create mode 100644 common/badtls/registry_utls.go create mode 100644 common/badversion/version.go create mode 100644 common/badversion/version_json.go create mode 100644 common/badversion/version_test.go create mode 100644 common/certificate/chrome.go create mode 100644 common/certificate/mozilla.go create mode 100644 common/certificate/store.go create mode 100644 common/compatible/map.go create mode 100644 common/convertor/adguard/convertor.go create mode 100644 common/convertor/adguard/convertor_test.go create mode 100644 common/dialer/default.go create mode 100644 common/dialer/default_parallel_interface.go create mode 100644 common/dialer/default_parallel_network.go create mode 100644 common/dialer/detour.go create mode 100644 common/dialer/dialer.go create mode 100644 common/dialer/resolve.go create mode 100644 common/dialer/router.go create mode 100644 common/dialer/tfo.go create mode 100644 common/dialer/wireguard.go create mode 100644 common/geoip/reader.go create mode 100644 common/geosite/compat_test.go create mode 100644 common/geosite/geosite_test.go create mode 100644 common/geosite/reader.go create mode 100644 common/geosite/rule.go create mode 100644 common/geosite/writer.go create mode 100644 common/interrupt/conn.go create mode 100644 common/interrupt/context.go create mode 100644 common/interrupt/group.go create mode 100644 common/ja3/LICENSE create mode 100644 common/ja3/README.md create mode 100644 common/ja3/error.go create mode 100644 common/ja3/ja3.go create mode 100644 common/ja3/parser.go create mode 100644 common/ktls/ktls.go create mode 100644 common/ktls/ktls_alert.go create mode 100644 common/ktls/ktls_cipher_suites_linux.go create mode 100644 common/ktls/ktls_close.go create mode 100644 common/ktls/ktls_const.go create mode 100644 common/ktls/ktls_handshake_messages.go create mode 100644 common/ktls/ktls_key_update.go create mode 100644 common/ktls/ktls_linux.go create mode 100644 common/ktls/ktls_prf.go create mode 100644 common/ktls/ktls_read.go create mode 100644 common/ktls/ktls_read_wait.go create mode 100644 common/ktls/ktls_stub_nolinkname.go create mode 100644 common/ktls/ktls_stub_nonlinux.go create mode 100644 common/ktls/ktls_stub_oldgo.go create mode 100644 common/ktls/ktls_write.go create mode 100644 common/listener/listener.go create mode 100644 common/listener/listener_tcp.go create mode 100644 common/listener/listener_udp.go create mode 100644 common/mux/client.go create mode 100644 common/mux/router.go create mode 100644 common/networkquality/http.go create mode 100644 common/networkquality/http3.go create mode 100644 common/networkquality/http3_stub.go create mode 100644 common/networkquality/networkquality.go create mode 100644 common/pipelistener/listener.go create mode 100644 common/process/searcher.go create mode 100644 common/process/searcher_android.go create mode 100644 common/process/searcher_darwin.go create mode 100644 common/process/searcher_darwin_shared.go create mode 100644 common/process/searcher_linux.go create mode 100644 common/process/searcher_linux_shared.go create mode 100644 common/process/searcher_linux_shared_test.go create mode 100644 common/process/searcher_stub.go create mode 100644 common/process/searcher_windows.go create mode 100644 common/redir/redir_darwin.go create mode 100644 common/redir/redir_linux.go create mode 100644 common/redir/redir_other.go create mode 100644 common/redir/tproxy_linux.go create mode 100644 common/redir/tproxy_other.go create mode 100644 common/settings/proxy_android.go create mode 100644 common/settings/proxy_darwin.go create mode 100644 common/settings/proxy_linux.go create mode 100644 common/settings/proxy_stub.go create mode 100644 common/settings/proxy_windows.go create mode 100644 common/settings/system_proxy.go create mode 100644 common/settings/wifi.go create mode 100644 common/settings/wifi_linux.go create mode 100644 common/settings/wifi_linux_connman.go create mode 100644 common/settings/wifi_linux_iwd.go create mode 100644 common/settings/wifi_linux_nm.go create mode 100644 common/settings/wifi_linux_wpa.go create mode 100644 common/settings/wifi_stub.go create mode 100644 common/settings/wifi_windows.go create mode 100644 common/sniff/bittorrent.go create mode 100644 common/sniff/bittorrent_test.go create mode 100644 common/sniff/dns.go create mode 100644 common/sniff/dns_test.go create mode 100644 common/sniff/dtls.go create mode 100644 common/sniff/dtls_test.go create mode 100644 common/sniff/http.go create mode 100644 common/sniff/http_test.go create mode 100644 common/sniff/internal/qtls/qtls.go create mode 100644 common/sniff/ntp.go create mode 100644 common/sniff/ntp_test.go create mode 100644 common/sniff/quic.go create mode 100644 common/sniff/quic_blacklist.go create mode 100644 common/sniff/quic_capture_test.go create mode 100644 common/sniff/quic_test.go create mode 100644 common/sniff/rdp.go create mode 100644 common/sniff/rdp_test.go create mode 100644 common/sniff/sniff.go create mode 100644 common/sniff/ssh.go create mode 100644 common/sniff/ssh_test.go create mode 100644 common/sniff/stun.go create mode 100644 common/sniff/stun_test.go create mode 100644 common/sniff/tls.go create mode 100644 common/srs/binary.go create mode 100644 common/srs/compat_test.go create mode 100644 common/srs/ip_cidr.go create mode 100644 common/srs/ip_set.go create mode 100644 common/stun/stun.go create mode 100644 common/taskmonitor/monitor.go create mode 100644 common/tls/acme.go create mode 100644 common/tls/acme_logger.go create mode 100644 common/tls/acme_stub.go create mode 100644 common/tls/client.go create mode 100644 common/tls/common.go create mode 100644 common/tls/config.go create mode 100644 common/tls/ech.go create mode 100644 common/tls/ech_shared.go create mode 100644 common/tls/ech_tag_stub.go create mode 100644 common/tls/ktls.go create mode 100644 common/tls/mkcert.go create mode 100644 common/tls/reality_client.go create mode 100644 common/tls/reality_server.go create mode 100644 common/tls/reality_stub.go create mode 100644 common/tls/server.go create mode 100644 common/tls/std_client.go create mode 100644 common/tls/std_server.go create mode 100644 common/tls/time_wrapper.go create mode 100644 common/tls/utls_client.go create mode 100644 common/tls/utls_stub.go create mode 100644 common/tlsfragment/conn.go create mode 100644 common/tlsfragment/conn_test.go create mode 100644 common/tlsfragment/index.go create mode 100644 common/tlsfragment/index_test.go create mode 100644 common/tlsfragment/wait_darwin.go create mode 100644 common/tlsfragment/wait_linux.go create mode 100644 common/tlsfragment/wait_stub.go create mode 100644 common/tlsfragment/wait_windows.go create mode 100644 common/uot/router.go create mode 100644 common/urltest/urltest.go create mode 100644 constant/certificate.go create mode 100644 constant/cgo.go create mode 100644 constant/cgo_disabled.go create mode 100644 constant/dhcp.go create mode 100644 constant/dns.go create mode 100644 constant/err.go create mode 100644 constant/goos/gengoos.go create mode 100644 constant/goos/goos.go create mode 100644 constant/goos/zgoos_aix.go create mode 100644 constant/goos/zgoos_android.go create mode 100644 constant/goos/zgoos_darwin.go create mode 100644 constant/goos/zgoos_dragonfly.go create mode 100644 constant/goos/zgoos_freebsd.go create mode 100644 constant/goos/zgoos_hurd.go create mode 100644 constant/goos/zgoos_illumos.go create mode 100644 constant/goos/zgoos_ios.go create mode 100644 constant/goos/zgoos_js.go create mode 100644 constant/goos/zgoos_linux.go create mode 100644 constant/goos/zgoos_netbsd.go create mode 100644 constant/goos/zgoos_openbsd.go create mode 100644 constant/goos/zgoos_plan9.go create mode 100644 constant/goos/zgoos_solaris.go create mode 100644 constant/goos/zgoos_windows.go create mode 100644 constant/goos/zgoos_zos.go create mode 100644 constant/hysteria2.go create mode 100644 constant/network.go create mode 100644 constant/os.go create mode 100644 constant/path.go create mode 100644 constant/path_unix.go create mode 100644 constant/protocol.go create mode 100644 constant/proxy.go create mode 100644 constant/quic.go create mode 100644 constant/quic_stub.go create mode 100644 constant/rule.go create mode 100644 constant/speed.go create mode 100644 constant/time.go create mode 100644 constant/timeout.go create mode 100644 constant/tls.go create mode 100644 constant/v2ray.go create mode 100644 constant/version.go create mode 100644 daemon/deprecated.go create mode 100644 daemon/instance.go create mode 100644 daemon/platform.go create mode 100644 daemon/started_service.go create mode 100644 daemon/started_service.pb.go create mode 100644 daemon/started_service.proto create mode 100644 daemon/started_service_grpc.pb.go create mode 100644 debug.go create mode 100644 debug_http.go create mode 100644 debug_stub.go create mode 100644 debug_unix.go create mode 100644 dns/client.go create mode 100644 dns/client_log.go create mode 100644 dns/client_truncate.go create mode 100644 dns/extension_edns0_subnet.go create mode 100644 dns/rcode.go create mode 100644 dns/repro_test.go create mode 100644 dns/router.go create mode 100644 dns/router_test.go create mode 100644 dns/transport/base.go create mode 100644 dns/transport/connector.go create mode 100644 dns/transport/connector_test.go create mode 100644 dns/transport/dhcp/dhcp.go create mode 100644 dns/transport/dhcp/dhcp_shared.go create mode 100644 dns/transport/fakeip/fakeip.go create mode 100644 dns/transport/fakeip/memory.go create mode 100644 dns/transport/fakeip/store.go create mode 100644 dns/transport/hosts/hosts.go create mode 100644 dns/transport/hosts/hosts_file.go create mode 100644 dns/transport/hosts/hosts_test.go create mode 100644 dns/transport/hosts/hosts_unix.go create mode 100644 dns/transport/hosts/hosts_windows.go create mode 100644 dns/transport/hosts/testdata/hosts create mode 100644 dns/transport/https.go create mode 100644 dns/transport/https_transport.go create mode 100644 dns/transport/local/local.go create mode 100644 dns/transport/local/local_darwin.go create mode 100644 dns/transport/local/local_darwin_cgo.go create mode 100644 dns/transport/local/local_darwin_dhcp.go create mode 100644 dns/transport/local/local_darwin_nodhcp.go create mode 100644 dns/transport/local/local_resolved.go create mode 100644 dns/transport/local/local_resolved_linux.go create mode 100644 dns/transport/local/local_resolved_stub.go create mode 100644 dns/transport/local/local_shared.go create mode 100644 dns/transport/local/resolv.go create mode 100644 dns/transport/local/resolv_default.go create mode 100644 dns/transport/local/resolv_test.go create mode 100644 dns/transport/local/resolv_unix.go create mode 100644 dns/transport/local/resolv_windows.go create mode 100644 dns/transport/quic/http3.go create mode 100644 dns/transport/quic/quic.go create mode 100644 dns/transport/tcp.go create mode 100644 dns/transport/tls.go create mode 100644 dns/transport/udp.go create mode 100644 dns/transport_adapter.go create mode 100644 dns/transport_dialer.go create mode 100644 dns/transport_manager.go create mode 100644 dns/transport_registry.go create mode 100644 docs/CNAME create mode 100644 docs/assets/icon.svg create mode 100644 docs/changelog.md create mode 100644 docs/clients/android/features.md create mode 100644 docs/clients/android/index.md create mode 100644 docs/clients/apple/features.md create mode 100644 docs/clients/apple/index.md create mode 100644 docs/clients/general.md create mode 100644 docs/clients/index.md create mode 100644 docs/clients/index.zh.md create mode 100644 docs/clients/privacy.md create mode 100644 docs/configuration/certificate/index.md create mode 100644 docs/configuration/certificate/index.zh.md create mode 100644 docs/configuration/dns/fakeip.md create mode 100644 docs/configuration/dns/fakeip.zh.md create mode 100644 docs/configuration/dns/index.md create mode 100644 docs/configuration/dns/index.zh.md create mode 100644 docs/configuration/dns/rule.md create mode 100644 docs/configuration/dns/rule.zh.md create mode 100644 docs/configuration/dns/rule_action.md create mode 100644 docs/configuration/dns/rule_action.zh.md create mode 100644 docs/configuration/dns/server/dhcp.md create mode 100644 docs/configuration/dns/server/dhcp.zh.md create mode 100644 docs/configuration/dns/server/fakeip.md create mode 100644 docs/configuration/dns/server/fakeip.zh.md create mode 100644 docs/configuration/dns/server/hosts.md create mode 100644 docs/configuration/dns/server/hosts.zh.md create mode 100644 docs/configuration/dns/server/http3.md create mode 100644 docs/configuration/dns/server/http3.zh.md create mode 100644 docs/configuration/dns/server/https.md create mode 100644 docs/configuration/dns/server/https.zh.md create mode 100644 docs/configuration/dns/server/index.md create mode 100644 docs/configuration/dns/server/index.zh.md create mode 100644 docs/configuration/dns/server/legacy.md create mode 100644 docs/configuration/dns/server/legacy.zh.md create mode 100644 docs/configuration/dns/server/local.md create mode 100644 docs/configuration/dns/server/local.zh.md create mode 100644 docs/configuration/dns/server/quic.md create mode 100644 docs/configuration/dns/server/quic.zh.md create mode 100644 docs/configuration/dns/server/resolved.md create mode 100644 docs/configuration/dns/server/resolved.zh.md create mode 100644 docs/configuration/dns/server/tailscale.md create mode 100644 docs/configuration/dns/server/tailscale.zh.md create mode 100644 docs/configuration/dns/server/tcp.md create mode 100644 docs/configuration/dns/server/tcp.zh.md create mode 100644 docs/configuration/dns/server/tls.md create mode 100644 docs/configuration/dns/server/tls.zh.md create mode 100644 docs/configuration/dns/server/udp.md create mode 100644 docs/configuration/dns/server/udp.zh.md create mode 100644 docs/configuration/endpoint/index.md create mode 100644 docs/configuration/endpoint/index.zh.md create mode 100644 docs/configuration/endpoint/tailscale.md create mode 100644 docs/configuration/endpoint/tailscale.zh.md create mode 100644 docs/configuration/endpoint/wireguard.md create mode 100644 docs/configuration/endpoint/wireguard.zh.md create mode 100644 docs/configuration/experimental/cache-file.md create mode 100644 docs/configuration/experimental/cache-file.zh.md create mode 100644 docs/configuration/experimental/clash-api.md create mode 100644 docs/configuration/experimental/clash-api.zh.md create mode 100644 docs/configuration/experimental/index.md create mode 100644 docs/configuration/experimental/index.zh.md create mode 100644 docs/configuration/experimental/v2ray-api.md create mode 100644 docs/configuration/experimental/v2ray-api.zh.md create mode 100644 docs/configuration/inbound/anytls.md create mode 100644 docs/configuration/inbound/anytls.zh.md create mode 100644 docs/configuration/inbound/cloudflared.md create mode 100644 docs/configuration/inbound/cloudflared.zh.md create mode 100644 docs/configuration/inbound/direct.md create mode 100644 docs/configuration/inbound/direct.zh.md create mode 100644 docs/configuration/inbound/http.md create mode 100644 docs/configuration/inbound/http.zh.md create mode 100644 docs/configuration/inbound/hysteria.md create mode 100644 docs/configuration/inbound/hysteria.zh.md create mode 100644 docs/configuration/inbound/hysteria2.md create mode 100644 docs/configuration/inbound/hysteria2.zh.md create mode 100644 docs/configuration/inbound/index.md create mode 100644 docs/configuration/inbound/index.zh.md create mode 100644 docs/configuration/inbound/mixed.md create mode 100644 docs/configuration/inbound/mixed.zh.md create mode 100644 docs/configuration/inbound/naive.md create mode 100644 docs/configuration/inbound/naive.zh.md create mode 100644 docs/configuration/inbound/redirect.md create mode 100644 docs/configuration/inbound/redirect.zh.md create mode 100644 docs/configuration/inbound/shadowsocks.md create mode 100644 docs/configuration/inbound/shadowsocks.zh.md create mode 100644 docs/configuration/inbound/shadowtls.md create mode 100644 docs/configuration/inbound/shadowtls.zh.md create mode 100644 docs/configuration/inbound/socks.md create mode 100644 docs/configuration/inbound/socks.zh.md create mode 100644 docs/configuration/inbound/tproxy.md create mode 100644 docs/configuration/inbound/tproxy.zh.md create mode 100644 docs/configuration/inbound/trojan.md create mode 100644 docs/configuration/inbound/trojan.zh.md create mode 100644 docs/configuration/inbound/tuic.md create mode 100644 docs/configuration/inbound/tuic.zh.md create mode 100644 docs/configuration/inbound/tun.md create mode 100644 docs/configuration/inbound/tun.zh.md create mode 100644 docs/configuration/inbound/vless.md create mode 100644 docs/configuration/inbound/vless.zh.md create mode 100644 docs/configuration/inbound/vmess.md create mode 100644 docs/configuration/inbound/vmess.zh.md create mode 100644 docs/configuration/index.md create mode 100644 docs/configuration/index.zh.md create mode 100644 docs/configuration/log/index.md create mode 100644 docs/configuration/log/index.zh.md create mode 100644 docs/configuration/ntp/index.md create mode 100644 docs/configuration/ntp/index.zh.md create mode 100644 docs/configuration/outbound/anytls.md create mode 100644 docs/configuration/outbound/anytls.zh.md create mode 100644 docs/configuration/outbound/block.md create mode 100644 docs/configuration/outbound/block.zh.md create mode 100644 docs/configuration/outbound/direct.md create mode 100644 docs/configuration/outbound/direct.zh.md create mode 100644 docs/configuration/outbound/dns.md create mode 100644 docs/configuration/outbound/dns.zh.md create mode 100644 docs/configuration/outbound/http.md create mode 100644 docs/configuration/outbound/http.zh.md create mode 100644 docs/configuration/outbound/hysteria.md create mode 100644 docs/configuration/outbound/hysteria.zh.md create mode 100644 docs/configuration/outbound/hysteria2.md create mode 100644 docs/configuration/outbound/hysteria2.zh.md create mode 100644 docs/configuration/outbound/index.md create mode 100644 docs/configuration/outbound/index.zh.md create mode 100644 docs/configuration/outbound/naive.md create mode 100644 docs/configuration/outbound/naive.zh.md create mode 100644 docs/configuration/outbound/selector.md create mode 100644 docs/configuration/outbound/selector.zh.md create mode 100644 docs/configuration/outbound/shadowsocks.md create mode 100644 docs/configuration/outbound/shadowsocks.zh.md create mode 100644 docs/configuration/outbound/shadowtls.md create mode 100644 docs/configuration/outbound/shadowtls.zh.md create mode 100644 docs/configuration/outbound/socks.md create mode 100644 docs/configuration/outbound/socks.zh.md create mode 100644 docs/configuration/outbound/ssh.md create mode 100644 docs/configuration/outbound/ssh.zh.md create mode 100644 docs/configuration/outbound/tor.md create mode 100644 docs/configuration/outbound/tor.zh.md create mode 100644 docs/configuration/outbound/trojan.md create mode 100644 docs/configuration/outbound/trojan.zh.md create mode 100644 docs/configuration/outbound/tuic.md create mode 100644 docs/configuration/outbound/tuic.zh.md create mode 100644 docs/configuration/outbound/urltest.md create mode 100644 docs/configuration/outbound/urltest.zh.md create mode 100644 docs/configuration/outbound/vless.md create mode 100644 docs/configuration/outbound/vless.zh.md create mode 100644 docs/configuration/outbound/vmess.md create mode 100644 docs/configuration/outbound/vmess.zh.md create mode 100644 docs/configuration/outbound/wireguard.md create mode 100644 docs/configuration/outbound/wireguard.zh.md create mode 100644 docs/configuration/route/geoip.md create mode 100644 docs/configuration/route/geoip.zh.md create mode 100644 docs/configuration/route/geosite.md create mode 100644 docs/configuration/route/geosite.zh.md create mode 100644 docs/configuration/route/index.md create mode 100644 docs/configuration/route/index.zh.md create mode 100644 docs/configuration/route/rule.md create mode 100644 docs/configuration/route/rule.zh.md create mode 100644 docs/configuration/route/rule_action.md create mode 100644 docs/configuration/route/rule_action.zh.md create mode 100644 docs/configuration/route/sniff.md create mode 100644 docs/configuration/route/sniff.zh.md create mode 100644 docs/configuration/rule-set/adguard.md create mode 100644 docs/configuration/rule-set/adguard.zh.md create mode 100644 docs/configuration/rule-set/headless-rule.md create mode 100644 docs/configuration/rule-set/headless-rule.zh.md create mode 100644 docs/configuration/rule-set/index.md create mode 100644 docs/configuration/rule-set/index.zh.md create mode 100644 docs/configuration/rule-set/source-format.md create mode 100644 docs/configuration/rule-set/source-format.zh.md create mode 100644 docs/configuration/service/ccm.md create mode 100644 docs/configuration/service/ccm.zh.md create mode 100644 docs/configuration/service/derp.md create mode 100644 docs/configuration/service/derp.zh.md create mode 100644 docs/configuration/service/index.md create mode 100644 docs/configuration/service/index.zh.md create mode 100644 docs/configuration/service/ocm.md create mode 100644 docs/configuration/service/ocm.zh.md create mode 100644 docs/configuration/service/resolved.md create mode 100644 docs/configuration/service/resolved.zh.md create mode 100644 docs/configuration/service/ssm-api.md create mode 100644 docs/configuration/service/ssm-api.zh.md create mode 100644 docs/configuration/shared/certificate-provider/acme.md create mode 100644 docs/configuration/shared/certificate-provider/acme.zh.md create mode 100644 docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md create mode 100644 docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md create mode 100644 docs/configuration/shared/certificate-provider/index.md create mode 100644 docs/configuration/shared/certificate-provider/index.zh.md create mode 100644 docs/configuration/shared/certificate-provider/tailscale.md create mode 100644 docs/configuration/shared/certificate-provider/tailscale.zh.md create mode 100644 docs/configuration/shared/dial.md create mode 100644 docs/configuration/shared/dial.zh.md create mode 100644 docs/configuration/shared/dns01_challenge.md create mode 100644 docs/configuration/shared/dns01_challenge.zh.md create mode 100644 docs/configuration/shared/listen.md create mode 100644 docs/configuration/shared/listen.zh.md create mode 100644 docs/configuration/shared/multiplex.md create mode 100644 docs/configuration/shared/multiplex.zh.md create mode 100644 docs/configuration/shared/neighbor.md create mode 100644 docs/configuration/shared/neighbor.zh.md create mode 100644 docs/configuration/shared/pre-match.md create mode 100644 docs/configuration/shared/pre-match.zh.md create mode 100644 docs/configuration/shared/tcp-brutal.md create mode 100644 docs/configuration/shared/tcp-brutal.zh.md create mode 100644 docs/configuration/shared/tls.md create mode 100644 docs/configuration/shared/tls.zh.md create mode 100644 docs/configuration/shared/udp-over-tcp.md create mode 100644 docs/configuration/shared/udp-over-tcp.zh.md create mode 100644 docs/configuration/shared/v2ray-transport.md create mode 100644 docs/configuration/shared/v2ray-transport.zh.md create mode 100644 docs/configuration/shared/wifi-state.md create mode 100644 docs/configuration/shared/wifi-state.zh.md create mode 100644 docs/deprecated.md create mode 100644 docs/deprecated.zh.md create mode 100644 docs/index.md create mode 100644 docs/index.zh.md create mode 100644 docs/installation/build-from-source.md create mode 100644 docs/installation/build-from-source.zh.md create mode 100644 docs/installation/docker.md create mode 100644 docs/installation/docker.zh.md create mode 100644 docs/installation/package-manager.md create mode 100644 docs/installation/package-manager.zh.md create mode 100644 docs/installation/tools/arch-install.sh create mode 100644 docs/installation/tools/deb-install.sh create mode 100644 docs/installation/tools/install.sh create mode 100644 docs/installation/tools/rpm-install.sh create mode 100644 docs/installation/tools/sing-box.repo create mode 100644 docs/manual/misc/tunnelvision.md create mode 100644 docs/manual/proxy-protocol/hysteria2.md create mode 100644 docs/manual/proxy-protocol/shadowsocks.md create mode 100644 docs/manual/proxy-protocol/trojan.md create mode 100644 docs/manual/proxy/client.md create mode 100644 docs/manual/proxy/server.md create mode 100644 docs/migration.md create mode 100644 docs/migration.zh.md create mode 100644 docs/sponsors.md create mode 100644 docs/support.md create mode 100644 docs/support.zh.md create mode 100644 experimental/cachefile/cache.go create mode 100644 experimental/cachefile/dns_cache.go create mode 100644 experimental/cachefile/fakeip.go create mode 100644 experimental/cachefile/rdrc.go create mode 100644 experimental/clashapi.go create mode 100644 experimental/clashapi/api_meta.go create mode 100644 experimental/clashapi/api_meta_group.go create mode 100644 experimental/clashapi/api_meta_upgrade.go create mode 100644 experimental/clashapi/cache.go create mode 100644 experimental/clashapi/common.go create mode 100644 experimental/clashapi/configs.go create mode 100644 experimental/clashapi/connections.go create mode 100644 experimental/clashapi/ctxkeys.go create mode 100644 experimental/clashapi/dns.go create mode 100644 experimental/clashapi/errors.go create mode 100644 experimental/clashapi/profile.go create mode 100644 experimental/clashapi/provider.go create mode 100644 experimental/clashapi/proxies.go create mode 100644 experimental/clashapi/ruleprovider.go create mode 100644 experimental/clashapi/rules.go create mode 100644 experimental/clashapi/script.go create mode 100644 experimental/clashapi/server.go create mode 100644 experimental/clashapi/server_fs.go create mode 100644 experimental/clashapi/server_resources.go create mode 100644 experimental/clashapi/trafficontrol/manager.go create mode 100644 experimental/clashapi/trafficontrol/tracker.go create mode 100644 experimental/deprecated/constants.go create mode 100644 experimental/deprecated/manager.go create mode 100644 experimental/deprecated/stderr.go create mode 100644 experimental/libbox/build_info.go create mode 100644 experimental/libbox/command.go create mode 100644 experimental/libbox/command_client.go create mode 100644 experimental/libbox/command_server.go create mode 100644 experimental/libbox/command_types.go create mode 100644 experimental/libbox/command_types_nq.go create mode 100644 experimental/libbox/command_types_stun.go create mode 100644 experimental/libbox/command_types_tailscale.go create mode 100644 experimental/libbox/command_types_tailscale_ping.go create mode 100644 experimental/libbox/config.go create mode 100644 experimental/libbox/connection_owner_darwin.go create mode 100644 experimental/libbox/debug.go create mode 100644 experimental/libbox/deprecated.go create mode 100644 experimental/libbox/dns.go create mode 100644 experimental/libbox/fdroid.go create mode 100644 experimental/libbox/fdroid_mirrors.go create mode 100644 experimental/libbox/ffi.json create mode 100644 experimental/libbox/http.go create mode 100644 experimental/libbox/internal/oomprofile/builder.go create mode 100644 experimental/libbox/internal/oomprofile/defs_darwin_amd64.go create mode 100644 experimental/libbox/internal/oomprofile/defs_darwin_arm64.go create mode 100644 experimental/libbox/internal/oomprofile/linkname.go create mode 100644 experimental/libbox/internal/oomprofile/mapping_darwin.go create mode 100644 experimental/libbox/internal/oomprofile/mapping_linux.go create mode 100644 experimental/libbox/internal/oomprofile/mapping_windows.go create mode 100644 experimental/libbox/internal/oomprofile/oomprofile.go create mode 100644 experimental/libbox/internal/oomprofile/protobuf.go create mode 100644 experimental/libbox/internal/procfs/procfs.go create mode 100644 experimental/libbox/iterator.go create mode 100644 experimental/libbox/link_flags_stub.go create mode 100644 experimental/libbox/link_flags_unix.go create mode 100644 experimental/libbox/log.go create mode 100644 experimental/libbox/monitor.go create mode 100644 experimental/libbox/neighbor.go create mode 100644 experimental/libbox/neighbor_darwin.go create mode 100644 experimental/libbox/neighbor_linux.go create mode 100644 experimental/libbox/neighbor_stub.go create mode 100644 experimental/libbox/networkquality.go create mode 100644 experimental/libbox/oom_report.go create mode 100644 experimental/libbox/panic.go create mode 100644 experimental/libbox/pidfd_android.go create mode 100644 experimental/libbox/platform.go create mode 100644 experimental/libbox/pprof.go create mode 100644 experimental/libbox/profile_import.go create mode 100644 experimental/libbox/remote_profile.go create mode 100644 experimental/libbox/report.go create mode 100644 experimental/libbox/semver.go create mode 100644 experimental/libbox/semver_test.go create mode 100644 experimental/libbox/service.go create mode 100644 experimental/libbox/service_other.go create mode 100644 experimental/libbox/service_windows.go create mode 100644 experimental/libbox/setup.go create mode 100644 experimental/libbox/signal_handler_darwin.go create mode 100644 experimental/libbox/signal_handler_stub.go create mode 100644 experimental/libbox/stun.go create mode 100644 experimental/libbox/tun.go create mode 100644 experimental/libbox/tun_darwin.go create mode 100644 experimental/libbox/tun_name_darwin.go create mode 100644 experimental/libbox/tun_name_linux.go create mode 100644 experimental/libbox/tun_name_other.go create mode 100644 experimental/locale/locale.go create mode 100644 experimental/locale/locale_zh_CN.go create mode 100644 experimental/v2rayapi.go create mode 100644 experimental/v2rayapi/server.go create mode 100644 experimental/v2rayapi/stats.go create mode 100644 experimental/v2rayapi/stats.pb.go create mode 100644 experimental/v2rayapi/stats.proto create mode 100644 experimental/v2rayapi/stats_grpc.pb.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 include/acme.go create mode 100644 include/acme_stub.go create mode 100644 include/ccm.go create mode 100644 include/ccm_stub.go create mode 100644 include/ccm_stub_darwin.go create mode 100644 include/clashapi.go create mode 100644 include/clashapi_stub.go create mode 100644 include/cloudflared.go create mode 100644 include/cloudflared_stub.go create mode 100644 include/dhcp.go create mode 100644 include/dhcp_stub.go create mode 100644 include/naive_outbound.go create mode 100644 include/naive_outbound_stub.go create mode 100644 include/ocm.go create mode 100644 include/ocm_stub.go create mode 100644 include/oom_killer.go create mode 100644 include/quic.go create mode 100644 include/quic_stub.go create mode 100644 include/registry.go create mode 100644 include/tailscale.go create mode 100644 include/tailscale_stub.go create mode 100644 include/tz_android.go create mode 100644 include/tz_ios.go create mode 100644 include/v2rayapi.go create mode 100644 include/v2rayapi_stub.go create mode 100644 include/wireguard.go create mode 100644 include/wireguard_stub.go create mode 100644 log/export.go create mode 100644 log/factory.go create mode 100644 log/format.go create mode 100644 log/id.go create mode 100644 log/level.go create mode 100644 log/log.go create mode 100644 log/nop.go create mode 100644 log/observable.go create mode 100644 log/override.go create mode 100644 log/platform.go create mode 100644 mkdocs.yml create mode 100644 option/acme.go create mode 100644 option/anytls.go create mode 100644 option/ccm.go create mode 100644 option/certificate.go create mode 100644 option/certificate_provider.go create mode 100644 option/cloudflared.go create mode 100644 option/debug.go create mode 100644 option/direct.go create mode 100644 option/dns.go create mode 100644 option/dns_record.go create mode 100644 option/dns_record_test.go create mode 100644 option/dns_test.go create mode 100644 option/endpoint.go create mode 100644 option/experimental.go create mode 100644 option/group.go create mode 100644 option/hysteria.go create mode 100644 option/hysteria2.go create mode 100644 option/inbound.go create mode 100644 option/multiplex.go create mode 100644 option/naive.go create mode 100644 option/ntp.go create mode 100644 option/ocm.go create mode 100644 option/oom_killer.go create mode 100644 option/options.go create mode 100644 option/origin_ca.go create mode 100644 option/outbound.go create mode 100644 option/platform.go create mode 100644 option/redir.go create mode 100644 option/resolved.go create mode 100644 option/route.go create mode 100644 option/rule.go create mode 100644 option/rule_action.go create mode 100644 option/rule_action_test.go create mode 100644 option/rule_dns.go create mode 100644 option/rule_nested.go create mode 100644 option/rule_nested_test.go create mode 100644 option/rule_set.go create mode 100644 option/service.go create mode 100644 option/shadowsocks.go create mode 100644 option/shadowsocksr.go create mode 100644 option/shadowtls.go create mode 100644 option/simple.go create mode 100644 option/ssh.go create mode 100644 option/ssmapi.go create mode 100644 option/tailscale.go create mode 100644 option/tls.go create mode 100644 option/tls_acme.go create mode 100644 option/tor.go create mode 100644 option/trojan.go create mode 100644 option/tuic.go create mode 100644 option/tun.go create mode 100644 option/tun_platform.go create mode 100644 option/types.go create mode 100644 option/udp_over_tcp.go create mode 100644 option/v2ray.go create mode 100644 option/v2ray_transport.go create mode 100644 option/vless.go create mode 100644 option/vmess.go create mode 100644 option/wireguard.go create mode 100644 protocol/anytls/inbound.go create mode 100644 protocol/anytls/outbound.go create mode 100644 protocol/block/outbound.go create mode 100644 protocol/cloudflare/inbound.go create mode 100644 protocol/direct/inbound.go create mode 100644 protocol/direct/loopback_detect.go create mode 100644 protocol/direct/outbound.go create mode 100644 protocol/dns/handle.go create mode 100644 protocol/dns/outbound.go create mode 100644 protocol/group/selector.go create mode 100644 protocol/group/urltest.go create mode 100644 protocol/http/inbound.go create mode 100644 protocol/http/outbound.go create mode 100644 protocol/hysteria/inbound.go create mode 100644 protocol/hysteria/outbound.go create mode 100644 protocol/hysteria2/inbound.go create mode 100644 protocol/hysteria2/outbound.go create mode 100644 protocol/mixed/inbound.go create mode 100644 protocol/naive/inbound.go create mode 100644 protocol/naive/inbound_conn.go create mode 100644 protocol/naive/outbound.go create mode 100644 protocol/naive/quic/inbound_init.go create mode 100644 protocol/redirect/redirect.go create mode 100644 protocol/redirect/tproxy.go create mode 100644 protocol/shadowsocks/inbound.go create mode 100644 protocol/shadowsocks/inbound_multi.go create mode 100644 protocol/shadowsocks/inbound_relay.go create mode 100644 protocol/shadowsocks/outbound.go create mode 100644 protocol/shadowtls/inbound.go create mode 100644 protocol/shadowtls/outbound.go create mode 100644 protocol/socks/inbound.go create mode 100644 protocol/socks/outbound.go create mode 100644 protocol/ssh/outbound.go create mode 100644 protocol/tailscale/certificate_provider.go create mode 100644 protocol/tailscale/dns_transport.go create mode 100644 protocol/tailscale/endpoint.go create mode 100644 protocol/tailscale/hostinfo_tvos.go create mode 100644 protocol/tailscale/ping.go create mode 100644 protocol/tailscale/status.go create mode 100644 protocol/tailscale/tun_device_unix.go create mode 100644 protocol/tailscale/tun_device_windows.go create mode 100644 protocol/tor/outbound.go create mode 100644 protocol/tor/proxy.go create mode 100644 protocol/trojan/inbound.go create mode 100644 protocol/trojan/outbound.go create mode 100644 protocol/tuic/inbound.go create mode 100644 protocol/tuic/outbound.go create mode 100644 protocol/tun/hook.go create mode 100644 protocol/tun/inbound.go create mode 100644 protocol/vless/inbound.go create mode 100644 protocol/vless/outbound.go create mode 100644 protocol/vmess/inbound.go create mode 100644 protocol/vmess/outbound.go create mode 100644 protocol/wireguard/endpoint.go create mode 100644 release/DEFAULT_BUILD_TAGS create mode 100644 release/DEFAULT_BUILD_TAGS_OTHERS create mode 100644 release/DEFAULT_BUILD_TAGS_WINDOWS create mode 100644 release/LDFLAGS create mode 100644 release/completions/sing-box.bash create mode 100644 release/completions/sing-box.fish create mode 100644 release/completions/sing-box.zsh create mode 100644 release/config/config.json create mode 100644 release/config/openwrt.conf create mode 100644 release/config/openwrt.init create mode 100644 release/config/openwrt.keep create mode 100644 release/config/openwrt.prerm create mode 100644 release/config/sing-box-split-dns.xml create mode 100644 release/config/sing-box.confd create mode 100644 release/config/sing-box.initd create mode 100644 release/config/sing-box.postinst create mode 100644 release/config/sing-box.rules create mode 100644 release/config/sing-box.service create mode 100644 release/config/sing-box.sysusers create mode 100644 release/config/sing-box@.service create mode 100644 release/local/common.sh create mode 100644 release/local/debug.sh create mode 100644 release/local/enable.sh create mode 100644 release/local/install.sh create mode 100644 release/local/install_go.sh create mode 100644 release/local/reinstall.sh create mode 100644 release/local/sing-box.service create mode 100644 release/local/uninstall.sh create mode 100644 release/local/update.sh create mode 100644 route/conn.go create mode 100644 route/dns.go create mode 100644 route/neighbor_resolver_darwin.go create mode 100644 route/neighbor_resolver_lease.go create mode 100644 route/neighbor_resolver_linux.go create mode 100644 route/neighbor_resolver_parse.go create mode 100644 route/neighbor_resolver_platform.go create mode 100644 route/neighbor_resolver_stub.go create mode 100644 route/neighbor_table_darwin.go create mode 100644 route/neighbor_table_linux.go create mode 100644 route/network.go create mode 100644 route/platform_searcher.go create mode 100644 route/process_cache.go create mode 100644 route/route.go create mode 100644 route/router.go create mode 100644 route/rule/match_state.go create mode 100644 route/rule/rule_abstract.go create mode 100644 route/rule/rule_abstract_test.go create mode 100644 route/rule/rule_action.go create mode 100644 route/rule/rule_default.go create mode 100644 route/rule/rule_default_interface_address.go create mode 100644 route/rule/rule_dns.go create mode 100644 route/rule/rule_headless.go create mode 100644 route/rule/rule_interface_address.go create mode 100644 route/rule/rule_item_adguard.go create mode 100644 route/rule/rule_item_auth_user.go create mode 100644 route/rule/rule_item_cidr.go create mode 100644 route/rule/rule_item_clash_mode.go create mode 100644 route/rule/rule_item_client.go create mode 100644 route/rule/rule_item_domain.go create mode 100644 route/rule/rule_item_domain_keyword.go create mode 100644 route/rule/rule_item_domain_regex.go create mode 100644 route/rule/rule_item_inbound.go create mode 100644 route/rule/rule_item_ip_accept_any.go create mode 100644 route/rule/rule_item_ip_is_private.go create mode 100644 route/rule/rule_item_ipversion.go create mode 100644 route/rule/rule_item_network.go create mode 100644 route/rule/rule_item_network_is_constrained.go create mode 100644 route/rule/rule_item_network_is_expensive.go create mode 100644 route/rule/rule_item_network_type.go create mode 100644 route/rule/rule_item_outbound.go create mode 100644 route/rule/rule_item_package_name.go create mode 100644 route/rule/rule_item_package_name_regex.go create mode 100644 route/rule/rule_item_port.go create mode 100644 route/rule/rule_item_port_range.go create mode 100644 route/rule/rule_item_preferred_by.go create mode 100644 route/rule/rule_item_process_name.go create mode 100644 route/rule/rule_item_process_path.go create mode 100644 route/rule/rule_item_process_path_regex.go create mode 100644 route/rule/rule_item_protocol.go create mode 100644 route/rule/rule_item_query_type.go create mode 100644 route/rule/rule_item_response_rcode.go create mode 100644 route/rule/rule_item_response_record.go create mode 100644 route/rule/rule_item_rule_set.go create mode 100644 route/rule/rule_item_rule_set_test.go create mode 100644 route/rule/rule_item_source_hostname.go create mode 100644 route/rule/rule_item_source_mac_address.go create mode 100644 route/rule/rule_item_user.go create mode 100644 route/rule/rule_item_user_id.go create mode 100644 route/rule/rule_item_wifi_bssid.go create mode 100644 route/rule/rule_item_wifi_ssid.go create mode 100644 route/rule/rule_nested_action.go create mode 100644 route/rule/rule_nested_action_test.go create mode 100644 route/rule/rule_network_interface_address.go create mode 100644 route/rule/rule_set.go create mode 100644 route/rule/rule_set_local.go create mode 100644 route/rule/rule_set_remote.go create mode 100644 route/rule/rule_set_semantics_test.go create mode 100644 route/rule/rule_set_update_validation_test.go create mode 100644 route/rule_conds.go create mode 100644 service/acme/service.go create mode 100644 service/acme/stub.go create mode 100644 service/ccm/credential.go create mode 100644 service/ccm/credential_darwin.go create mode 100644 service/ccm/credential_other.go create mode 100644 service/ccm/service.go create mode 100644 service/ccm/service_usage.go create mode 100644 service/ccm/service_user.go create mode 100644 service/derp/service.go create mode 100644 service/ocm/credential.go create mode 100644 service/ocm/credential_darwin.go create mode 100644 service/ocm/credential_other.go create mode 100644 service/ocm/service.go create mode 100644 service/ocm/service_usage.go create mode 100644 service/ocm/service_user.go create mode 100644 service/ocm/service_websocket.go create mode 100644 service/oomkiller/policy.go create mode 100644 service/oomkiller/service.go create mode 100644 service/oomkiller/service_darwin.go create mode 100644 service/oomkiller/service_stub.go create mode 100644 service/oomkiller/timer.go create mode 100644 service/origin_ca/service.go create mode 100644 service/resolved/resolve1.go create mode 100644 service/resolved/service.go create mode 100644 service/resolved/stub.go create mode 100644 service/resolved/transport.go create mode 100644 service/ssmapi/api.go create mode 100644 service/ssmapi/cache.go create mode 100644 service/ssmapi/server.go create mode 100644 service/ssmapi/traffic.go create mode 100644 service/ssmapi/user.go create mode 100644 test/box_test.go create mode 100644 test/brutal_test.go create mode 100644 test/clash_darwin_test.go create mode 100644 test/clash_other_test.go create mode 100644 test/clash_test.go create mode 100644 test/config/hysteria-client.json create mode 100644 test/config/hysteria-server.json create mode 100644 test/config/hysteria2-client.yml create mode 100644 test/config/hysteria2-server.yml create mode 100644 test/config/naive-nginx.conf create mode 100644 test/config/naive-quic.json create mode 100644 test/config/naive.json create mode 100644 test/config/nginx.conf create mode 100644 test/config/shadowsocksr.json create mode 100644 test/config/trojan.json create mode 100644 test/config/tuic-client.json create mode 100644 test/config/tuic-server.json create mode 100644 test/config/vless-server.json create mode 100644 test/config/vless-tls-client.json create mode 100644 test/config/vless-tls-server.json create mode 100644 test/config/vmess-client.json create mode 100644 test/config/vmess-grpc-client.json create mode 100644 test/config/vmess-grpc-server.json create mode 100644 test/config/vmess-mux-client.json create mode 100644 test/config/vmess-server.json create mode 100644 test/config/vmess-ws-client.json create mode 100644 test/config/vmess-ws-server.json create mode 100644 test/config/wireguard.conf create mode 100644 test/direct_test.go create mode 100644 test/docker_test.go create mode 100644 test/domain_inbound_test.go create mode 100644 test/ech_test.go create mode 100644 test/go.mod create mode 100644 test/go.sum create mode 100644 test/http_test.go create mode 100644 test/hysteria2_test.go create mode 100644 test/hysteria_test.go create mode 100644 test/inbound_detour_test.go create mode 100644 test/ktls_test.go create mode 100644 test/mkcert.go create mode 100644 test/mux_cool_test.go create mode 100644 test/mux_test.go create mode 100644 test/naive_self_test.go create mode 100644 test/naive_test.go create mode 100644 test/reality_test.go create mode 100644 test/shadowsocks_legacy_test.go create mode 100644 test/shadowsocks_test.go create mode 100644 test/shadowtls_test.go create mode 100644 test/socks_test.go create mode 100644 test/ss_plugin_test.go create mode 100644 test/tfo_test.go create mode 100644 test/tls_test.go create mode 100644 test/trojan_test.go create mode 100644 test/tuic_test.go create mode 100644 test/v2ray_api_test.go create mode 100644 test/v2ray_grpc_test.go create mode 100644 test/v2ray_httpupgrade_test.go create mode 100644 test/v2ray_transport_test.go create mode 100644 test/v2ray_ws_test.go create mode 100644 test/vmess_test.go create mode 100644 test/wrapper_test.go create mode 100644 transport/simple-obfs/README.md create mode 100644 transport/simple-obfs/http.go create mode 100644 transport/simple-obfs/tls.go create mode 100644 transport/sip003/args.go create mode 100644 transport/sip003/obfs.go create mode 100644 transport/sip003/plugin.go create mode 100644 transport/sip003/v2ray.go create mode 100644 transport/trojan/mux.go create mode 100644 transport/trojan/protocol.go create mode 100644 transport/trojan/protocol_wait.go create mode 100644 transport/trojan/service.go create mode 100644 transport/trojan/service_wait.go create mode 100644 transport/v2ray/grpc.go create mode 100644 transport/v2ray/grpc_lite.go create mode 100644 transport/v2ray/quic.go create mode 100644 transport/v2ray/transport.go create mode 100644 transport/v2raygrpc/client.go create mode 100644 transport/v2raygrpc/conn.go create mode 100644 transport/v2raygrpc/credentials/credentials.go create mode 100644 transport/v2raygrpc/credentials/spiffe.go create mode 100644 transport/v2raygrpc/credentials/syscallconn.go create mode 100644 transport/v2raygrpc/credentials/util.go create mode 100644 transport/v2raygrpc/custom_name.go create mode 100644 transport/v2raygrpc/server.go create mode 100644 transport/v2raygrpc/stream.pb.go create mode 100644 transport/v2raygrpc/stream.proto create mode 100644 transport/v2raygrpc/stream_grpc.pb.go create mode 100644 transport/v2raygrpc/tls_credentials.go create mode 100644 transport/v2raygrpclite/client.go create mode 100644 transport/v2raygrpclite/conn.go create mode 100644 transport/v2raygrpclite/server.go create mode 100644 transport/v2rayhttp/client.go create mode 100644 transport/v2rayhttp/conn.go create mode 100644 transport/v2rayhttp/force_close.go create mode 100644 transport/v2rayhttp/pool.go create mode 100644 transport/v2rayhttp/server.go create mode 100644 transport/v2rayhttpupgrade/client.go create mode 100644 transport/v2rayhttpupgrade/server.go create mode 100644 transport/v2rayquic/client.go create mode 100644 transport/v2rayquic/init.go create mode 100644 transport/v2rayquic/server.go create mode 100644 transport/v2rayquic/stream.go create mode 100644 transport/v2raywebsocket/client.go create mode 100644 transport/v2raywebsocket/conn.go create mode 100644 transport/v2raywebsocket/server.go create mode 100644 transport/v2raywebsocket/writer.go create mode 100644 transport/wireguard/client_bind.go create mode 100644 transport/wireguard/device.go create mode 100644 transport/wireguard/device_nat.go create mode 100644 transport/wireguard/device_stack.go create mode 100644 transport/wireguard/device_stack_gonet.go create mode 100644 transport/wireguard/device_stack_stub.go create mode 100644 transport/wireguard/device_system.go create mode 100644 transport/wireguard/device_system_stack.go create mode 100644 transport/wireguard/endpoint.go create mode 100644 transport/wireguard/endpoint_options.go diff --git a/.fpm_openwrt b/.fpm_openwrt new file mode 100644 index 00000000..3223ec8a --- /dev/null +++ b/.fpm_openwrt @@ -0,0 +1,31 @@ +-s dir +--name sing-box +--category net +--license GPL-3.0-or-later +--description "The universal proxy platform." +--url "https://sing-box.sagernet.org/" +--maintainer "nekohasekai " +--no-deb-generate-changes + +--config-files /etc/config/sing-box +--config-files /etc/sing-box/config.json + +--depends ca-bundle +--depends kmod-inet-diag +--depends kmod-tun +--depends firewall4 +--depends kmod-nft-queue + +--before-remove release/config/openwrt.prerm + +release/config/config.json=/etc/sing-box/config.json + +release/config/openwrt.conf=/etc/config/sing-box +release/config/openwrt.init=/etc/init.d/sing-box +release/config/openwrt.keep=/lib/upgrade/keep.d/sing-box + +release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash +release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish +release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box + +LICENSE=/usr/share/licenses/sing-box/LICENSE diff --git a/.fpm_pacman b/.fpm_pacman new file mode 100644 index 00000000..8c86dfd9 --- /dev/null +++ b/.fpm_pacman @@ -0,0 +1,23 @@ +-s dir +--name sing-box +--category net +--license GPL-3.0-or-later +--description "The universal proxy platform." +--url "https://sing-box.sagernet.org/" +--maintainer "nekohasekai " +--config-files etc/sing-box/config.json +--after-install release/config/sing-box.postinst + +release/config/config.json=/etc/sing-box/config.json + +release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service +release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service +release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf +release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules +release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf + +release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash +release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish +release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box + +LICENSE=/usr/share/licenses/sing-box/LICENSE diff --git a/.fpm_systemd b/.fpm_systemd new file mode 100644 index 00000000..9b455da9 --- /dev/null +++ b/.fpm_systemd @@ -0,0 +1,26 @@ +-s dir +--name sing-box +--category net +--license GPL-3.0-or-later +--description "The universal proxy platform." +--url "https://sing-box.sagernet.org/" +--vendor SagerNet +--maintainer "nekohasekai " +--deb-field "Bug: https://github.com/SagerNet/sing-box/issues" +--no-deb-generate-changes +--config-files /etc/sing-box/config.json +--after-install release/config/sing-box.postinst + +release/config/config.json=/etc/sing-box/config.json + +release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service +release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service +release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf +release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules +release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf + +release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash +release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish +release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box + +LICENSE=/usr/share/licenses/sing-box/LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d2b74d08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +/.idea/ +/vendor/ +/*.json +/*.srs +/*.db +/site/ +/bin/ +/dist/ +/sing-box +/sing-box.exe +/build/ +/*.jar +/*.aar +/*.xcframework/ +/experimental/libbox/*.aar +/experimental/libbox/*.xcframework/ +/experimental/libbox/*.nupkg +.DS_Store +/config.d/ +/venv/ +CLAUDE.md +AGENTS.md +/.claude/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..45ffb563 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "clients/apple"] + path = clients/apple + url = https://github.com/SagerNet/sing-box-for-apple.git +[submodule "clients/android"] + path = clients/android + url = https://github.com/SagerNet/sing-box-for-android.git diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..d6905dc1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,64 @@ +version: "2" +run: + go: "1.25" + build-tags: + - with_gvisor + - with_quic + - with_dhcp + - with_wireguard + - with_utls + - with_acme + - with_clash_api + - with_tailscale + - with_ccm + - with_ocm + - badlinkname + - tfogo_checklinkname0 +linters: + default: none + enable: + - govet + - ineffassign + - paralleltest + - staticcheck + settings: + staticcheck: + checks: + - all + - -S1000 + - -S1008 + - -S1017 + - -ST1003 + - -QF1001 + - -QF1003 + - -QF1008 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - transport/simple-obfs + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofumpt + settings: + gci: + sections: + - standard + - prefix(github.com/sagernet/) + - default + custom-order: true + exclusions: + generated: lax + paths: + - transport/simple-obfs + - third_party$ + - builtin$ + - examples$ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c8600d57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +LABEL maintainer="nekohasekai " +COPY . /go/src/github.com/sagernet/sing-box +WORKDIR /go/src/github.com/sagernet/sing-box +ARG TARGETOS TARGETARCH +ARG GOPROXY="" +ENV GOPROXY ${GOPROXY} +ENV CGO_ENABLED=0 +ENV GOOS=$TARGETOS +ENV GOARCH=$TARGETARCH +RUN set -ex \ + && apk add git build-base \ + && export COMMIT=$(git rev-parse --short HEAD) \ + && export VERSION=$(go run ./cmd/internal/read_tag) \ + && export TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) \ + && export LDFLAGS_SHARED=$(cat release/LDFLAGS) \ + && go build -v -trimpath -tags "$TAGS" \ + -o /go/bin/sing-box \ + -ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" $LDFLAGS_SHARED -s -w -buildid=" \ + ./cmd/sing-box +FROM --platform=$TARGETPLATFORM alpine AS dist +LABEL maintainer="nekohasekai " +RUN set -ex \ + && apk add --no-cache --upgrade bash tzdata ca-certificates nftables +COPY --from=builder /go/bin/sing-box /usr/local/bin/sing-box +ENTRYPOINT ["sing-box"] diff --git a/Dockerfile.binary b/Dockerfile.binary new file mode 100644 index 00000000..78fc5667 --- /dev/null +++ b/Dockerfile.binary @@ -0,0 +1,14 @@ +ARG BASE_IMAGE=alpine +FROM ${BASE_IMAGE} +ARG TARGETARCH +ARG TARGETVARIANT +LABEL maintainer="nekohasekai " +RUN set -ex \ + && if command -v apk > /dev/null; then \ + apk add --no-cache --upgrade bash tzdata ca-certificates nftables; \ + else \ + apt-get update && apt-get install -y --no-install-recommends bash tzdata ca-certificates nftables \ + && rm -rf /var/lib/apt/lists/*; \ + fi +COPY sing-box-${TARGETARCH}${TARGETVARIANT} /usr/local/bin/sing-box +ENTRYPOINT ["sing-box"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..175f3503 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..1a1138cc --- /dev/null +++ b/Makefile @@ -0,0 +1,276 @@ +NAME = sing-box +COMMIT = $(shell git rev-parse --short HEAD) +TAGS ?= $(shell cat release/DEFAULT_BUILD_TAGS_OTHERS) + +GOHOSTOS = $(shell go env GOHOSTOS) +GOHOSTARCH = $(shell go env GOHOSTARCH) +VERSION=$(shell CGO_ENABLED=0 GOOS=$(GOHOSTOS) GOARCH=$(GOHOSTARCH) go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest) + +LDFLAGS_SHARED = $(shell cat release/LDFLAGS) +PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$(VERSION)' $(LDFLAGS_SHARED) -s -w -buildid=" +MAIN_PARAMS = $(PARAMS) -tags "$(TAGS)" +MAIN = ./cmd/sing-box +PREFIX ?= $(shell go env GOPATH) +SING_FFI ?= sing-ffi +LIBBOX_FFI_CONFIG ?= ./experimental/libbox/ffi.json + +.PHONY: test release docs build + +build: + export GOTOOLCHAIN=local && \ + go build $(MAIN_PARAMS) $(MAIN) + +race: + export GOTOOLCHAIN=local && \ + go build -race $(MAIN_PARAMS) $(MAIN) + +ci_build: + export GOTOOLCHAIN=local && \ + go build $(PARAMS) $(MAIN) && \ + go build $(MAIN_PARAMS) $(MAIN) + +generate_completions: + go run -v --tags "$(TAGS),generate,generate_completions" $(MAIN) + +install: + go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN) + +fmt: + @gofumpt -l -w . + @gofmt -s -w . + @gci write --custom-order -s standard -s "prefix(github.com/sagernet/)" -s "default" . + +fmt_docs: + go run ./cmd/internal/format_docs + +fmt_install: + go install -v mvdan.cc/gofumpt@latest + go install -v github.com/daixiang0/gci@latest + +lint: + GOOS=linux golangci-lint run ./... + GOOS=android golangci-lint run ./... + GOOS=windows golangci-lint run ./... + GOOS=darwin golangci-lint run ./... + GOOS=freebsd golangci-lint run ./... + +lint_install: + go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + +proto: + @go run ./cmd/internal/protogen + @gofumpt -l -w . + @gofumpt -l -w . + +proto_install: + go install -v google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install -v google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + +update_certificates: + go run ./cmd/internal/update_certificates + +release: + go run ./cmd/internal/build goreleaser release --clean --skip publish + mkdir dist/release + mv dist/*.tar.gz \ + dist/*.zip \ + dist/*.deb \ + dist/*.rpm \ + dist/*_amd64.pkg.tar.zst \ + dist/*_arm64.pkg.tar.zst \ + dist/release + ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release + rm -r dist/release + +release_repo: + go run ./cmd/internal/build goreleaser release -f .goreleaser.fury.yaml --clean + +release_install: + go install -v github.com/tcnksm/ghr@latest + +update_android_version: + go run ./cmd/internal/update_android_version + +build_android: + cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease :app:assembleOtherLegacyRelease && ./gradlew --stop + +upload_android: + mkdir -p dist/release_android + cp ../sing-box-for-android/app/build/outputs/apk/other/release/*.apk dist/release_android + cp ../sing-box-for-android/app/build/outputs/apk/otherLegacy/release/*.apk dist/release_android + ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android + rm -rf dist/release_android + +release_android: lib_android update_android_version build_android upload_android + +publish_android: + cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop + +# TODO: find why and remove `-destination 'generic/platform=iOS'` +# TODO: remove xcode clean when fix control widget fixed +build_ios: + cd ../sing-box-for-apple && \ + rm -rf build/SFI.xcarchive && \ + xcodebuild clean -scheme SFI && \ + xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌" + +upload_ios_app_store: + cd ../sing-box-for-apple && \ + xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates + +export_ios_ipa: + cd ../sing-box-for-apple && \ + xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Export.plist -allowProvisioningUpdates -exportPath build/SFI && \ + cp build/SFI/sing-box.ipa dist/SFI.ipa + +upload_ios_ipa: + cd dist && \ + cp SFI.ipa "SFI-${VERSION}.ipa" && \ + ghr --replace --draft --prerelease "v${VERSION}" "SFI-${VERSION}.ipa" + +release_ios: build_ios upload_ios_app_store + +build_macos: + cd ../sing-box-for-apple && \ + rm -rf build/SFM.xcarchive && \ + xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌" + +upload_macos_app_store: + cd ../sing-box-for-apple && \ + xcodebuild -exportArchive -archivePath build/SFM.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates + +release_macos: build_macos upload_macos_app_store + +build_macos_standalone: + $(MAKE) -C ../sing-box-for-apple archive_macos_standalone + +build_macos_dmg: + $(MAKE) -C ../sing-box-for-apple build_macos_dmg + +build_macos_pkg: + $(MAKE) -C ../sing-box-for-apple build_macos_pkg + +notarize_macos_dmg: + $(MAKE) -C ../sing-box-for-apple notarize_macos_dmg + +notarize_macos_pkg: + $(MAKE) -C ../sing-box-for-apple notarize_macos_pkg + +upload_macos_dmg: + mkdir -p dist/SFM + cp ../sing-box-for-apple/build/SFM-Apple.dmg "dist/SFM/SFM-${VERSION}-Apple.dmg" + cp ../sing-box-for-apple/build/SFM-Intel.dmg "dist/SFM/SFM-${VERSION}-Intel.dmg" + cp ../sing-box-for-apple/build/SFM-Universal.dmg "dist/SFM/SFM-${VERSION}-Universal.dmg" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.dmg" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.dmg" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.dmg" + +upload_macos_pkg: + mkdir -p dist/SFM + cp ../sing-box-for-apple/build/SFM-Apple.pkg "dist/SFM/SFM-${VERSION}-Apple.pkg" + cp ../sing-box-for-apple/build/SFM-Intel.pkg "dist/SFM/SFM-${VERSION}-Intel.pkg" + cp ../sing-box-for-apple/build/SFM-Universal.pkg "dist/SFM/SFM-${VERSION}-Universal.pkg" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.pkg" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.pkg" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.pkg" + +upload_macos_dsyms: + mkdir -p dist/SFM + cd ../sing-box-for-apple/build/SFM.System-universal.xcarchive && zip -r SFM.dSYMs.zip dSYMs + cp ../sing-box-for-apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM/SFM-${VERSION}.dSYMs.zip" + ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}.dSYMs.zip" + +release_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg upload_macos_dsyms + +build_tvos: + cd ../sing-box-for-apple && \ + rm -rf build/SFT.xcarchive && \ + xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌" + +upload_tvos_app_store: + cd ../sing-box-for-apple && \ + xcodebuild -exportArchive -archivePath "build/SFT.xcarchive" -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates + +export_tvos_ipa: + cd ../sing-box-for-apple && \ + xcodebuild -exportArchive -archivePath "build/SFT.xcarchive" -exportOptionsPlist SFI/Export.plist -allowProvisioningUpdates -exportPath build/SFT && \ + cp build/SFT/sing-box.ipa dist/SFT.ipa + +upload_tvos_ipa: + cd dist && \ + cp SFT.ipa "SFT-${VERSION}.ipa" && \ + ghr --replace --draft --prerelease "v${VERSION}" "SFT-${VERSION}.ipa" + +release_tvos: build_tvos upload_tvos_app_store + +update_apple_version: + go run ./cmd/internal/update_apple_version + +update_macos_version: + MACOS_PROJECT_VERSION=$(shell go run -v ./cmd/internal/app_store_connect next_macos_project_version) go run ./cmd/internal/update_apple_version + +release_apple: lib_apple update_apple_version release_ios release_macos release_tvos release_macos_standalone + +release_apple_beta: update_apple_version release_ios release_macos release_tvos + +publish_testflight: + go run -v ./cmd/internal/app_store_connect publish_testflight $(filter-out $@,$(MAKECMDGOALS)) + +prepare_app_store: + go run -v ./cmd/internal/app_store_connect prepare_app_store + +publish_app_store: + go run -v ./cmd/internal/app_store_connect publish_app_store + +test: + @go test -v ./... && \ + cd test && \ + go mod tidy && \ + go test -v -tags "$(TAGS_TEST)" . + +test_stdio: + @go test -v ./... && \ + cd test && \ + go mod tidy && \ + go test -v -tags "$(TAGS_TEST),force_stdio" . + +lib_android: + go run ./cmd/internal/build_libbox -target android + +lib_apple: + go run ./cmd/internal/build_libbox -target apple + +lib_windows: + $(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type csharp + +lib_android_new: + $(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type android + +lib_apple_new: + $(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple + +lib_install: + go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12 + go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12 + +docs: + venv/bin/mkdocs serve + +publish_docs: + venv/bin/mkdocs gh-deploy -m "Update" --force --ignore-version --no-history + +docs_install: + python3 -m venv venv + source ./venv/bin/activate && pip install --force-reinstall mkdocs-material=="9.7.2" mkdocs-static-i18n=="1.2.*" + +clean: + rm -rf bin dist sing-box + rm -f $(shell go env GOPATH)/sing-box + +update: + git fetch + git reset FETCH_HEAD --hard + git clean -fdx + +%: + @: diff --git a/README.md b/README.md new file mode 100644 index 00000000..90be2a83 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +> Sponsored by [Warp](https://go.warp.dev/sing-box), built for coding with multiple AI agents + + +Warp sponsorship + + +--- + +# sing-box + +The universal proxy platform. + +[![Packaging status](https://repology.org/badge/vertical-allrepos/sing-box.svg)](https://repology.org/project/sing-box/versions) + +## Documentation + +https://sing-box.sagernet.org + +## License + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. +``` \ No newline at end of file diff --git a/adapter/certificate.go b/adapter/certificate.go new file mode 100644 index 00000000..0998e130 --- /dev/null +++ b/adapter/certificate.go @@ -0,0 +1,21 @@ +package adapter + +import ( + "context" + "crypto/x509" + + "github.com/sagernet/sing/service" +) + +type CertificateStore interface { + LifecycleService + Pool() *x509.CertPool +} + +func RootPoolFromContext(ctx context.Context) *x509.CertPool { + store := service.FromContext[CertificateStore](ctx) + if store == nil { + return nil + } + return store.Pool() +} diff --git a/adapter/certificate/adapter.go b/adapter/certificate/adapter.go new file mode 100644 index 00000000..802020c1 --- /dev/null +++ b/adapter/certificate/adapter.go @@ -0,0 +1,21 @@ +package certificate + +type Adapter struct { + providerType string + providerTag string +} + +func NewAdapter(providerType string, providerTag string) Adapter { + return Adapter{ + providerType: providerType, + providerTag: providerTag, + } +} + +func (a *Adapter) Type() string { + return a.providerType +} + +func (a *Adapter) Tag() string { + return a.providerTag +} diff --git a/adapter/certificate/manager.go b/adapter/certificate/manager.go new file mode 100644 index 00000000..e4b9b535 --- /dev/null +++ b/adapter/certificate/manager.go @@ -0,0 +1,158 @@ +package certificate + +import ( + "context" + "os" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ adapter.CertificateProviderManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.CertificateProviderRegistry + access sync.Mutex + started bool + stage adapter.StartStage + providers []adapter.CertificateProviderService + providerByTag map[string]adapter.CertificateProviderService +} + +func NewManager(logger log.ContextLogger, registry adapter.CertificateProviderRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + providerByTag: make(map[string]adapter.CertificateProviderService), + } +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + providers := m.providers + m.access.Unlock() + for _, provider := range providers { + name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err := adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Close() error { + m.access.Lock() + defer m.access.Unlock() + if !m.started { + return nil + } + m.started = false + providers := m.providers + m.providers = nil + monitor := taskmonitor.New(m.logger, C.StopTimeout) + var err error + for _, provider := range providers { + name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" + m.logger.Trace("close ", name) + startTime := time.Now() + monitor.Start("close ", name) + err = E.Append(err, provider.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + monitor.Finish() + m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return err +} + +func (m *Manager) CertificateProviders() []adapter.CertificateProviderService { + m.access.Lock() + defer m.access.Unlock() + return m.providers +} + +func (m *Manager) Get(tag string) (adapter.CertificateProviderService, bool) { + m.access.Lock() + provider, found := m.providerByTag[tag] + m.access.Unlock() + return provider, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + provider, found := m.providerByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.providerByTag, tag) + index := common.Index(m.providers, func(it adapter.CertificateProviderService) bool { + return it == provider + }) + if index == -1 { + panic("invalid certificate provider index") + } + m.providers = append(m.providers[:index], m.providers[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return provider.Close() + } + return nil +} + +func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error { + provider, err := m.registry.Create(ctx, logger, tag, providerType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" + for _, stage := range adapter.ListStartStages { + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err = adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + if existsProvider, loaded := m.providerByTag[tag]; loaded { + if m.started { + err = existsProvider.Close() + if err != nil { + return E.Cause(err, "close certificate-provider/", existsProvider.Type(), "[", existsProvider.Tag(), "]") + } + } + existsIndex := common.Index(m.providers, func(it adapter.CertificateProviderService) bool { + return it == existsProvider + }) + if existsIndex == -1 { + panic("invalid certificate provider index") + } + m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...) + } + m.providers = append(m.providers, provider) + m.providerByTag[tag] = provider + return nil +} diff --git a/adapter/certificate/registry.go b/adapter/certificate/registry.go new file mode 100644 index 00000000..5a080f2c --- /dev/null +++ b/adapter/certificate/registry.go @@ -0,0 +1,72 @@ +package certificate + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, logger log.ContextLogger, tag string, options T) (adapter.CertificateProviderService, error) + +func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) { + registry.register(providerType, func() any { + return new(Options) + }, func(ctx context.Context, logger log.ContextLogger, tag string, rawOptions any) (adapter.CertificateProviderService, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.CertificateProviderRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, logger log.ContextLogger, tag string, options any) (adapter.CertificateProviderService, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructor map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructor: make(map[string]constructorFunc), + } +} + +func (m *Registry) CreateOptions(providerType string) (any, bool) { + m.access.Lock() + defer m.access.Unlock() + optionsConstructor, loaded := m.optionsType[providerType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (m *Registry) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (adapter.CertificateProviderService, error) { + m.access.Lock() + defer m.access.Unlock() + constructor, loaded := m.constructor[providerType] + if !loaded { + return nil, E.New("certificate provider type not found: " + providerType) + } + return constructor(ctx, logger, tag, options) +} + +func (m *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + m.access.Lock() + defer m.access.Unlock() + m.optionsType[providerType] = optionsConstructor + m.constructor[providerType] = constructor +} diff --git a/adapter/certificate_provider.go b/adapter/certificate_provider.go new file mode 100644 index 00000000..70bdeb88 --- /dev/null +++ b/adapter/certificate_provider.go @@ -0,0 +1,38 @@ +package adapter + +import ( + "context" + "crypto/tls" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +type CertificateProvider interface { + GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) +} + +type ACMECertificateProvider interface { + CertificateProvider + GetACMENextProtos() []string +} + +type CertificateProviderService interface { + Lifecycle + Type() string + Tag() string + CertificateProvider +} + +type CertificateProviderRegistry interface { + option.CertificateProviderOptionsRegistry + Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (CertificateProviderService, error) +} + +type CertificateProviderManager interface { + Lifecycle + CertificateProviders() []CertificateProviderService + Get(tag string) (CertificateProviderService, bool) + Remove(tag string) error + Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error +} diff --git a/adapter/connections.go b/adapter/connections.go new file mode 100644 index 00000000..a0b9c0ef --- /dev/null +++ b/adapter/connections.go @@ -0,0 +1,18 @@ +package adapter + +import ( + "context" + "net" + + N "github.com/sagernet/sing/common/network" +) + +type ConnectionManager interface { + Lifecycle + Count() int + CloseAll() + TrackConn(conn net.Conn) net.Conn + TrackPacketConn(conn net.PacketConn) net.PacketConn + NewConnection(ctx context.Context, this N.Dialer, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc) + NewPacketConnection(ctx context.Context, this N.Dialer, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc) +} diff --git a/adapter/dns.go b/adapter/dns.go new file mode 100644 index 00000000..7e2c5fa0 --- /dev/null +++ b/adapter/dns.go @@ -0,0 +1,98 @@ +package adapter + +import ( + "context" + "net/netip" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" + + "github.com/miekg/dns" +) + +type DNSRouter interface { + Lifecycle + Exchange(ctx context.Context, message *dns.Msg, options DNSQueryOptions) (*dns.Msg, error) + Lookup(ctx context.Context, domain string, options DNSQueryOptions) ([]netip.Addr, error) + ClearCache() + LookupReverseMapping(ip netip.Addr) (string, bool) + ResetNetwork() +} + +type DNSClient interface { + Start() + Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) + Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) + ClearCache() +} + +type DNSQueryOptions struct { + Transport DNSTransport + Strategy C.DomainStrategy + LookupStrategy C.DomainStrategy + DisableCache bool + DisableOptimisticCache bool + RewriteTTL *uint32 + ClientSubnet netip.Prefix +} + +func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (*DNSQueryOptions, error) { + if options == nil { + return &DNSQueryOptions{}, nil + } + transportManager := service.FromContext[DNSTransportManager](ctx) + transport, loaded := transportManager.Transport(options.Server) + if !loaded { + return nil, E.New("domain resolver not found: " + options.Server) + } + return &DNSQueryOptions{ + Transport: transport, + Strategy: C.DomainStrategy(options.Strategy), + DisableCache: options.DisableCache, + DisableOptimisticCache: options.DisableOptimisticCache, + RewriteTTL: options.RewriteTTL, + ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}), + }, nil +} + +type RDRCStore interface { + LoadRDRC(transportName string, qName string, qType uint16) (rejected bool) + SaveRDRC(transportName string, qName string, qType uint16) error + SaveRDRCAsync(transportName string, qName string, qType uint16, logger logger.Logger) +} + +type DNSCacheStore interface { + LoadDNSCache(transportName string, qName string, qType uint16) (rawMessage []byte, expireAt time.Time, loaded bool) + SaveDNSCache(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time) error + SaveDNSCacheAsync(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time, logger logger.Logger) + ClearDNSCache() error +} + +type DNSTransport interface { + Lifecycle + Type() string + Tag() string + Dependencies() []string + Reset() + Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) +} + +type DNSTransportRegistry interface { + option.DNSTransportOptionsRegistry + CreateDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) (DNSTransport, error) +} + +type DNSTransportManager interface { + Lifecycle + Transports() []DNSTransport + Transport(tag string) (DNSTransport, bool) + Default() DNSTransport + FakeIP() FakeIPTransport + Remove(tag string) error + Create(ctx context.Context, logger log.ContextLogger, tag string, outboundType string, options any) error +} diff --git a/adapter/endpoint.go b/adapter/endpoint.go new file mode 100644 index 00000000..f09f08ce --- /dev/null +++ b/adapter/endpoint.go @@ -0,0 +1,28 @@ +package adapter + +import ( + "context" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +type Endpoint interface { + Lifecycle + Type() string + Tag() string + Outbound +} + +type EndpointRegistry interface { + option.EndpointOptionsRegistry + Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, endpointType string, options any) (Endpoint, error) +} + +type EndpointManager interface { + Lifecycle + Endpoints() []Endpoint + Get(tag string) (Endpoint, bool) + Remove(tag string) error + Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, endpointType string, options any) error +} diff --git a/adapter/endpoint/adapter.go b/adapter/endpoint/adapter.go new file mode 100644 index 00000000..c75e4d83 --- /dev/null +++ b/adapter/endpoint/adapter.go @@ -0,0 +1,43 @@ +package endpoint + +import "github.com/sagernet/sing-box/option" + +type Adapter struct { + endpointType string + endpointTag string + network []string + dependencies []string +} + +func NewAdapter(endpointType string, endpointTag string, network []string, dependencies []string) Adapter { + return Adapter{ + endpointType: endpointType, + endpointTag: endpointTag, + network: network, + dependencies: dependencies, + } +} + +func NewAdapterWithDialerOptions(endpointType string, endpointTag string, network []string, dialOptions option.DialerOptions) Adapter { + var dependencies []string + if dialOptions.Detour != "" { + dependencies = []string{dialOptions.Detour} + } + return NewAdapter(endpointType, endpointTag, network, dependencies) +} + +func (a *Adapter) Type() string { + return a.endpointType +} + +func (a *Adapter) Tag() string { + return a.endpointTag +} + +func (a *Adapter) Network() []string { + return a.network +} + +func (a *Adapter) Dependencies() []string { + return a.dependencies +} diff --git a/adapter/endpoint/manager.go b/adapter/endpoint/manager.go new file mode 100644 index 00000000..8b7c287f --- /dev/null +++ b/adapter/endpoint/manager.go @@ -0,0 +1,161 @@ +package endpoint + +import ( + "context" + "os" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ adapter.EndpointManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.EndpointRegistry + access sync.Mutex + started bool + stage adapter.StartStage + endpoints []adapter.Endpoint + endpointByTag map[string]adapter.Endpoint +} + +func NewManager(logger log.ContextLogger, registry adapter.EndpointRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + endpointByTag: make(map[string]adapter.Endpoint), + } +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + defer m.access.Unlock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + if stage == adapter.StartStateStart { + // started with outbound manager + return nil + } + for _, endpoint := range m.endpoints { + name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err := adapter.LegacyStart(endpoint, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Close() error { + m.access.Lock() + defer m.access.Unlock() + if !m.started { + return nil + } + m.started = false + endpoints := m.endpoints + m.endpoints = nil + monitor := taskmonitor.New(m.logger, C.StopTimeout) + var err error + for _, endpoint := range endpoints { + name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" + m.logger.Trace("close ", name) + startTime := time.Now() + monitor.Start("close ", name) + err = E.Append(err, endpoint.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + monitor.Finish() + m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Endpoints() []adapter.Endpoint { + m.access.Lock() + defer m.access.Unlock() + return m.endpoints +} + +func (m *Manager) Get(tag string) (adapter.Endpoint, bool) { + m.access.Lock() + defer m.access.Unlock() + endpoint, found := m.endpointByTag[tag] + return endpoint, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + endpoint, found := m.endpointByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.endpointByTag, tag) + index := common.Index(m.endpoints, func(it adapter.Endpoint) bool { + return it == endpoint + }) + if index == -1 { + panic("invalid endpoint index") + } + m.endpoints = append(m.endpoints[:index], m.endpoints[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return endpoint.Close() + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) error { + endpoint, err := m.registry.Create(ctx, router, logger, tag, outboundType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" + for _, stage := range adapter.ListStartStages { + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err = adapter.LegacyStart(endpoint, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + if existsEndpoint, loaded := m.endpointByTag[tag]; loaded { + if m.started { + err = existsEndpoint.Close() + if err != nil { + return E.Cause(err, "close endpoint/", existsEndpoint.Type(), "[", existsEndpoint.Tag(), "]") + } + } + existsIndex := common.Index(m.endpoints, func(it adapter.Endpoint) bool { + return it == existsEndpoint + }) + if existsIndex == -1 { + panic("invalid endpoint index") + } + m.endpoints = append(m.endpoints[:existsIndex], m.endpoints[existsIndex+1:]...) + } + m.endpoints = append(m.endpoints, endpoint) + m.endpointByTag[tag] = endpoint + return nil +} diff --git a/adapter/endpoint/registry.go b/adapter/endpoint/registry.go new file mode 100644 index 00000000..92cb9025 --- /dev/null +++ b/adapter/endpoint/registry.go @@ -0,0 +1,72 @@ +package endpoint + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options T) (adapter.Endpoint, error) + +func Register[Options any](registry *Registry, outboundType string, constructor ConstructorFunc[Options]) { + registry.register(outboundType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, rawOptions any) (adapter.Endpoint, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.EndpointRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options any) (adapter.Endpoint, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructor map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructor: make(map[string]constructorFunc), + } +} + +func (m *Registry) CreateOptions(outboundType string) (any, bool) { + m.access.Lock() + defer m.access.Unlock() + optionsConstructor, loaded := m.optionsType[outboundType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (m *Registry) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Endpoint, error) { + m.access.Lock() + defer m.access.Unlock() + constructor, loaded := m.constructor[outboundType] + if !loaded { + return nil, E.New("outbound type not found: " + outboundType) + } + return constructor(ctx, router, logger, tag, options) +} + +func (m *Registry) register(outboundType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + m.access.Lock() + defer m.access.Unlock() + m.optionsType[outboundType] = optionsConstructor + m.constructor[outboundType] = constructor +} diff --git a/adapter/experimental.go b/adapter/experimental.go new file mode 100644 index 00000000..49fd2bd3 --- /dev/null +++ b/adapter/experimental.go @@ -0,0 +1,152 @@ +package adapter + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "time" + + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/common/varbin" +) + +type ClashServer interface { + LifecycleService + ConnectionTracker + Mode() string + ModeList() []string + SetModeUpdateHook(hook *observable.Subscriber[struct{}]) + HistoryStorage() URLTestHistoryStorage +} + +type URLTestHistory struct { + Time time.Time `json:"time"` + Delay uint16 `json:"delay"` +} + +type URLTestHistoryStorage interface { + SetHook(hook *observable.Subscriber[struct{}]) + LoadURLTestHistory(tag string) *URLTestHistory + DeleteURLTestHistory(tag string) + StoreURLTestHistory(tag string, history *URLTestHistory) + Close() error +} + +type V2RayServer interface { + LifecycleService + StatsService() ConnectionTracker +} + +type CacheFile interface { + LifecycleService + + StoreFakeIP() bool + FakeIPStorage + + StoreRDRC() bool + RDRCStore + + StoreDNS() bool + DNSCacheStore + + SetDisableExpire(disableExpire bool) + SetOptimisticTimeout(timeout time.Duration) + + LoadMode() string + StoreMode(mode string) error + LoadSelected(group string) string + StoreSelected(group string, selected string) error + LoadGroupExpand(group string) (isExpand bool, loaded bool) + StoreGroupExpand(group string, expand bool) error + LoadRuleSet(tag string) *SavedBinary + SaveRuleSet(tag string, set *SavedBinary) error +} + +type SavedBinary struct { + Content []byte + LastUpdated time.Time + LastEtag string +} + +func (s *SavedBinary) MarshalBinary() ([]byte, error) { + var buffer bytes.Buffer + err := binary.Write(&buffer, binary.BigEndian, uint8(1)) + if err != nil { + return nil, err + } + _, err = varbin.WriteUvarint(&buffer, uint64(len(s.Content))) + if err != nil { + return nil, err + } + _, err = buffer.Write(s.Content) + if err != nil { + return nil, err + } + err = binary.Write(&buffer, binary.BigEndian, s.LastUpdated.Unix()) + if err != nil { + return nil, err + } + _, err = varbin.WriteUvarint(&buffer, uint64(len(s.LastEtag))) + if err != nil { + return nil, err + } + _, err = buffer.WriteString(s.LastEtag) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (s *SavedBinary) UnmarshalBinary(data []byte) error { + reader := bytes.NewReader(data) + var version uint8 + err := binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return err + } + contentLength, err := binary.ReadUvarint(reader) + if err != nil { + return err + } + s.Content = make([]byte, contentLength) + _, err = io.ReadFull(reader, s.Content) + if err != nil { + return err + } + var lastUpdated int64 + err = binary.Read(reader, binary.BigEndian, &lastUpdated) + if err != nil { + return err + } + s.LastUpdated = time.Unix(lastUpdated, 0) + etagLength, err := binary.ReadUvarint(reader) + if err != nil { + return err + } + etagBytes := make([]byte, etagLength) + _, err = io.ReadFull(reader, etagBytes) + if err != nil { + return err + } + s.LastEtag = string(etagBytes) + return nil +} + +type OutboundGroup interface { + Outbound + Now() string + All() []string +} + +type URLTestGroup interface { + OutboundGroup + URLTest(ctx context.Context) (map[string]uint16, error) +} + +func OutboundTag(detour Outbound) string { + if group, isGroup := detour.(OutboundGroup); isGroup { + return group.Now() + } + return detour.Tag() +} diff --git a/adapter/fakeip.go b/adapter/fakeip.go new file mode 100644 index 00000000..0787c146 --- /dev/null +++ b/adapter/fakeip.go @@ -0,0 +1,31 @@ +package adapter + +import ( + "net/netip" + + "github.com/sagernet/sing/common/logger" +) + +type FakeIPStore interface { + SimpleLifecycle + Contains(address netip.Addr) bool + Create(domain string, isIPv6 bool) (netip.Addr, error) + Lookup(address netip.Addr) (string, bool) + Reset() error +} + +type FakeIPStorage interface { + FakeIPMetadata() *FakeIPMetadata + FakeIPSaveMetadata(metadata *FakeIPMetadata) error + FakeIPSaveMetadataAsync(metadata *FakeIPMetadata) + FakeIPStore(address netip.Addr, domain string) error + FakeIPStoreAsync(address netip.Addr, domain string, logger logger.Logger) + FakeIPLoad(address netip.Addr) (string, bool) + FakeIPLoadDomain(domain string, isIPv6 bool) (netip.Addr, bool) + FakeIPReset() error +} + +type FakeIPTransport interface { + DNSTransport + Store() FakeIPStore +} diff --git a/adapter/fakeip_metadata.go b/adapter/fakeip_metadata.go new file mode 100644 index 00000000..7df77d42 --- /dev/null +++ b/adapter/fakeip_metadata.go @@ -0,0 +1,50 @@ +package adapter + +import ( + "bytes" + "encoding" + "encoding/binary" + "io" + "net/netip" + + "github.com/sagernet/sing/common" +) + +type FakeIPMetadata struct { + Inet4Range netip.Prefix + Inet6Range netip.Prefix + Inet4Current netip.Addr + Inet6Current netip.Addr +} + +func (m *FakeIPMetadata) MarshalBinary() (data []byte, err error) { + var buffer bytes.Buffer + for _, marshaler := range []encoding.BinaryMarshaler{m.Inet4Range, m.Inet6Range, m.Inet4Current, m.Inet6Current} { + data, err = marshaler.MarshalBinary() + if err != nil { + return + } + common.Must(binary.Write(&buffer, binary.BigEndian, uint16(len(data)))) + buffer.Write(data) + } + data = buffer.Bytes() + return +} + +func (m *FakeIPMetadata) UnmarshalBinary(data []byte) error { + reader := bytes.NewReader(data) + for _, unmarshaler := range []encoding.BinaryUnmarshaler{&m.Inet4Range, &m.Inet6Range, &m.Inet4Current, &m.Inet6Current} { + var length uint16 + common.Must(binary.Read(reader, binary.BigEndian, &length)) + element := make([]byte, length) + _, err := io.ReadFull(reader, element) + if err != nil { + return err + } + err = unmarshaler.UnmarshalBinary(element) + if err != nil { + return err + } + } + return nil +} diff --git a/adapter/handler.go b/adapter/handler.go new file mode 100644 index 00000000..f8912110 --- /dev/null +++ b/adapter/handler.go @@ -0,0 +1,61 @@ +package adapter + +import ( + "context" + "net" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +// Deprecated +type ConnectionHandler interface { + NewConnection(ctx context.Context, conn net.Conn, metadata InboundContext) error +} + +type ConnectionHandlerEx interface { + NewConnectionEx(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc) +} + +// Deprecated: use PacketHandlerEx instead +type PacketHandler interface { + NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, metadata InboundContext) error +} + +type PacketHandlerEx interface { + NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) +} + +// Deprecated: use OOBPacketHandlerEx instead +type OOBPacketHandler interface { + NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, oob []byte, metadata InboundContext) error +} + +type OOBPacketHandlerEx interface { + NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr) +} + +// Deprecated +type PacketConnectionHandler interface { + NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext) error +} + +type PacketConnectionHandlerEx interface { + NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc) +} + +// Deprecated: use TCPConnectionHandlerEx instead +// +//nolint:staticcheck +type UpstreamHandlerAdapter interface { + N.TCPConnectionHandler + N.UDPConnectionHandler + E.Handler +} + +type UpstreamHandlerAdapterEx interface { + N.TCPConnectionHandlerEx + N.UDPConnectionHandlerEx +} diff --git a/adapter/inbound.go b/adapter/inbound.go new file mode 100644 index 00000000..6f53b122 --- /dev/null +++ b/adapter/inbound.go @@ -0,0 +1,195 @@ +package adapter + +import ( + "context" + "net" + "net/netip" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + M "github.com/sagernet/sing/common/metadata" + + "github.com/miekg/dns" +) + +type Inbound interface { + Lifecycle + Type() string + Tag() string +} + +type TCPInjectableInbound interface { + Inbound + ConnectionHandlerEx +} + +type UDPInjectableInbound interface { + Inbound + PacketConnectionHandlerEx +} + +type InboundRegistry interface { + option.InboundOptionsRegistry + Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) (Inbound, error) +} + +type InboundManager interface { + Lifecycle + Inbounds() []Inbound + Get(tag string) (Inbound, bool) + Remove(tag string) error + Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) error +} + +type InboundContext struct { + Inbound string + InboundType string + IPVersion uint8 + Network string + Source M.Socksaddr + Destination M.Socksaddr + User string + Outbound string + + // sniffer + + Protocol string + Domain string + Client string + SniffContext any + SnifferNames []string + SniffError error + + // cache + + // Deprecated: implement in rule action + InboundDetour string + LastInbound string + OriginDestination M.Socksaddr + RouteOriginalDestination M.Socksaddr + UDPDisableDomainUnmapping bool + UDPConnect bool + UDPTimeout time.Duration + TLSFragment bool + TLSFragmentFallbackDelay time.Duration + TLSRecordFragment bool + + NetworkStrategy *C.NetworkStrategy + NetworkType []C.InterfaceType + FallbackNetworkType []C.InterfaceType + FallbackDelay time.Duration + + DestinationAddresses []netip.Addr + DNSResponse *dns.Msg + DestinationAddressMatchFromResponse bool + SourceGeoIPCode string + GeoIPCode string + ProcessInfo *ConnectionOwner + SourceMACAddress net.HardwareAddr + SourceHostname string + QueryType uint16 + FakeIP bool + + // rule cache + + IPCIDRMatchSource bool + IPCIDRAcceptEmpty bool + + SourceAddressMatch bool + SourcePortMatch bool + DestinationAddressMatch bool + DestinationPortMatch bool + DidMatch bool + IgnoreDestinationIPCIDRMatch bool +} + +func (c *InboundContext) ResetRuleCache() { + c.IPCIDRMatchSource = false + c.IPCIDRAcceptEmpty = false + c.ResetRuleMatchCache() +} + +func (c *InboundContext) ResetRuleMatchCache() { + c.SourceAddressMatch = false + c.SourcePortMatch = false + c.DestinationAddressMatch = false + c.DestinationPortMatch = false + c.DidMatch = false +} + +func (c *InboundContext) DNSResponseAddressesForMatch() []netip.Addr { + return DNSResponseAddresses(c.DNSResponse) +} + +func DNSResponseAddresses(response *dns.Msg) []netip.Addr { + if response == nil || response.Rcode != dns.RcodeSuccess { + return nil + } + addresses := make([]netip.Addr, 0, len(response.Answer)) + for _, rawRecord := range response.Answer { + switch record := rawRecord.(type) { + case *dns.A: + addr := M.AddrFromIP(record.A) + if addr.IsValid() { + addresses = append(addresses, addr) + } + case *dns.AAAA: + addr := M.AddrFromIP(record.AAAA) + if addr.IsValid() { + addresses = append(addresses, addr) + } + case *dns.HTTPS: + for _, value := range record.SVCB.Value { + switch hint := value.(type) { + case *dns.SVCBIPv4Hint: + for _, ip := range hint.Hint { + addr := M.AddrFromIP(ip).Unmap() + if addr.IsValid() { + addresses = append(addresses, addr) + } + } + case *dns.SVCBIPv6Hint: + for _, ip := range hint.Hint { + addr := M.AddrFromIP(ip) + if addr.IsValid() { + addresses = append(addresses, addr) + } + } + } + } + } + } + return addresses +} + +type inboundContextKey struct{} + +func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { + return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext) +} + +func ContextFrom(ctx context.Context) *InboundContext { + metadata := ctx.Value((*inboundContextKey)(nil)) + if metadata == nil { + return nil + } + return metadata.(*InboundContext) +} + +func ExtendContext(ctx context.Context) (context.Context, *InboundContext) { + var newMetadata InboundContext + if metadata := ContextFrom(ctx); metadata != nil { + newMetadata = *metadata + } + return WithContext(ctx, &newMetadata), &newMetadata +} + +func OverrideContext(ctx context.Context) context.Context { + if metadata := ContextFrom(ctx); metadata != nil { + newMetadata := *metadata + return WithContext(ctx, &newMetadata) + } + return ctx +} diff --git a/adapter/inbound/adapter.go b/adapter/inbound/adapter.go new file mode 100644 index 00000000..1426104a --- /dev/null +++ b/adapter/inbound/adapter.go @@ -0,0 +1,21 @@ +package inbound + +type Adapter struct { + inboundType string + inboundTag string +} + +func NewAdapter(inboundType string, inboundTag string) Adapter { + return Adapter{ + inboundType: inboundType, + inboundTag: inboundTag, + } +} + +func (a *Adapter) Type() string { + return a.inboundType +} + +func (a *Adapter) Tag() string { + return a.inboundTag +} diff --git a/adapter/inbound/manager.go b/adapter/inbound/manager.go new file mode 100644 index 00000000..438c20f4 --- /dev/null +++ b/adapter/inbound/manager.go @@ -0,0 +1,163 @@ +package inbound + +import ( + "context" + "os" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ adapter.InboundManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.InboundRegistry + endpoint adapter.EndpointManager + access sync.Mutex + started bool + stage adapter.StartStage + inbounds []adapter.Inbound + inboundByTag map[string]adapter.Inbound +} + +func NewManager(logger log.ContextLogger, registry adapter.InboundRegistry, endpoint adapter.EndpointManager) *Manager { + return &Manager{ + logger: logger, + registry: registry, + endpoint: endpoint, + inboundByTag: make(map[string]adapter.Inbound), + } +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + inbounds := m.inbounds + m.access.Unlock() + for _, inbound := range inbounds { + name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err := adapter.LegacyStart(inbound, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Close() error { + m.access.Lock() + defer m.access.Unlock() + if !m.started { + return nil + } + m.started = false + inbounds := m.inbounds + m.inbounds = nil + monitor := taskmonitor.New(m.logger, C.StopTimeout) + var err error + for _, inbound := range inbounds { + name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" + m.logger.Trace("close ", name) + startTime := time.Now() + monitor.Start("close ", name) + err = E.Append(err, inbound.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + monitor.Finish() + m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Inbounds() []adapter.Inbound { + m.access.Lock() + defer m.access.Unlock() + return m.inbounds +} + +func (m *Manager) Get(tag string) (adapter.Inbound, bool) { + m.access.Lock() + inbound, found := m.inboundByTag[tag] + m.access.Unlock() + if found { + return inbound, true + } + return m.endpoint.Get(tag) +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + inbound, found := m.inboundByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.inboundByTag, tag) + index := common.Index(m.inbounds, func(it adapter.Inbound) bool { + return it == inbound + }) + if index == -1 { + panic("invalid inbound index") + } + m.inbounds = append(m.inbounds[:index], m.inbounds[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return inbound.Close() + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) error { + inbound, err := m.registry.Create(ctx, router, logger, tag, outboundType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" + for _, stage := range adapter.ListStartStages { + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err = adapter.LegacyStart(inbound, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + if existsInbound, loaded := m.inboundByTag[tag]; loaded { + if m.started { + err = existsInbound.Close() + if err != nil { + return E.Cause(err, "close inbound/", existsInbound.Type(), "[", existsInbound.Tag(), "]") + } + } + existsIndex := common.Index(m.inbounds, func(it adapter.Inbound) bool { + return it == existsInbound + }) + if existsIndex == -1 { + panic("invalid inbound index") + } + m.inbounds = append(m.inbounds[:existsIndex], m.inbounds[existsIndex+1:]...) + } + m.inbounds = append(m.inbounds, inbound) + m.inboundByTag[tag] = inbound + return nil +} diff --git a/adapter/inbound/registry.go b/adapter/inbound/registry.go new file mode 100644 index 00000000..01e367d8 --- /dev/null +++ b/adapter/inbound/registry.go @@ -0,0 +1,72 @@ +package inbound + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options T) (adapter.Inbound, error) + +func Register[Options any](registry *Registry, outboundType string, constructor ConstructorFunc[Options]) { + registry.register(outboundType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, rawOptions any) (adapter.Inbound, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.InboundRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options any) (adapter.Inbound, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructor map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructor: make(map[string]constructorFunc), + } +} + +func (m *Registry) CreateOptions(outboundType string) (any, bool) { + m.access.Lock() + defer m.access.Unlock() + optionsConstructor, loaded := m.optionsType[outboundType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (m *Registry) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Inbound, error) { + m.access.Lock() + defer m.access.Unlock() + constructor, loaded := m.constructor[outboundType] + if !loaded { + return nil, E.New("outbound type not found: " + outboundType) + } + return constructor(ctx, router, logger, tag, options) +} + +func (m *Registry) register(outboundType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + m.access.Lock() + defer m.access.Unlock() + m.optionsType[outboundType] = optionsConstructor + m.constructor[outboundType] = constructor +} diff --git a/adapter/inbound_test.go b/adapter/inbound_test.go new file mode 100644 index 00000000..ec8c3128 --- /dev/null +++ b/adapter/inbound_test.go @@ -0,0 +1,45 @@ +package adapter + +import ( + "net" + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestDNSResponseAddressesUnmapsHTTPSIPv4Hints(t *testing.T) { + t.Parallel() + + ipv4Hint := net.ParseIP("1.1.1.1") + require.NotNil(t, ipv4Hint) + + response := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Response: true, + Rcode: dns.RcodeSuccess, + }, + Answer: []dns.RR{ + &dns.HTTPS{ + SVCB: dns.SVCB{ + Hdr: dns.RR_Header{ + Name: dns.Fqdn("example.com"), + Rrtype: dns.TypeHTTPS, + Class: dns.ClassINET, + Ttl: 60, + }, + Priority: 1, + Target: ".", + Value: []dns.SVCBKeyValue{ + &dns.SVCBIPv4Hint{Hint: []net.IP{ipv4Hint}}, + }, + }, + }, + }, + } + + addresses := DNSResponseAddresses(response) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) + require.True(t, addresses[0].Is4()) +} diff --git a/adapter/lifecycle.go b/adapter/lifecycle.go new file mode 100644 index 00000000..b969c98a --- /dev/null +++ b/adapter/lifecycle.go @@ -0,0 +1,102 @@ +package adapter + +import ( + "reflect" + "strings" + "time" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +type SimpleLifecycle interface { + Start() error + Close() error +} + +type StartStage uint8 + +const ( + StartStateInitialize StartStage = iota + StartStateStart + StartStatePostStart + StartStateStarted +) + +var ListStartStages = []StartStage{ + StartStateInitialize, + StartStateStart, + StartStatePostStart, + StartStateStarted, +} + +func (s StartStage) String() string { + switch s { + case StartStateInitialize: + return "initialize" + case StartStateStart: + return "start" + case StartStatePostStart: + return "post-start" + case StartStateStarted: + return "finish-start" + default: + panic("unknown stage") + } +} + +type Lifecycle interface { + Start(stage StartStage) error + Close() error +} + +type LifecycleService interface { + Name() string + Lifecycle +} + +func getServiceName(service any) string { + if named, ok := service.(interface { + Type() string + Tag() string + }); ok { + tag := named.Tag() + if tag != "" { + return named.Type() + "[" + tag + "]" + } + return named.Type() + } + t := reflect.TypeOf(service) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + return strings.ToLower(t.Name()) +} + +func Start(logger log.ContextLogger, stage StartStage, services ...Lifecycle) error { + for _, service := range services { + name := getServiceName(service) + logger.Trace(stage, " ", name) + startTime := time.Now() + err := service.Start(stage) + if err != nil { + return err + } + logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func StartNamed(logger log.ContextLogger, stage StartStage, services []LifecycleService) error { + for _, service := range services { + logger.Trace(stage, " ", service.Name()) + startTime := time.Now() + err := service.Start(stage) + if err != nil { + return E.Cause(err, stage.String(), " ", service.Name()) + } + logger.Trace(stage, " ", service.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} diff --git a/adapter/lifecycle_legacy.go b/adapter/lifecycle_legacy.go new file mode 100644 index 00000000..f8b25db6 --- /dev/null +++ b/adapter/lifecycle_legacy.go @@ -0,0 +1,52 @@ +package adapter + +func LegacyStart(starter any, stage StartStage) error { + if lifecycle, isLifecycle := starter.(Lifecycle); isLifecycle { + return lifecycle.Start(stage) + } + switch stage { + case StartStateInitialize: + if preStarter, isPreStarter := starter.(interface { + PreStart() error + }); isPreStarter { + return preStarter.PreStart() + } + case StartStateStart: + if starter, isStarter := starter.(interface { + Start() error + }); isStarter { + return starter.Start() + } + case StartStateStarted: + if postStarter, isPostStarter := starter.(interface { + PostStart() error + }); isPostStarter { + return postStarter.PostStart() + } + } + return nil +} + +type lifecycleServiceWrapper struct { + SimpleLifecycle + name string +} + +func NewLifecycleService(service SimpleLifecycle, name string) LifecycleService { + return &lifecycleServiceWrapper{ + SimpleLifecycle: service, + name: name, + } +} + +func (l *lifecycleServiceWrapper) Name() string { + return l.name +} + +func (l *lifecycleServiceWrapper) Start(stage StartStage) error { + return LegacyStart(l.SimpleLifecycle, stage) +} + +func (l *lifecycleServiceWrapper) Close() error { + return l.SimpleLifecycle.Close() +} diff --git a/adapter/neighbor.go b/adapter/neighbor.go new file mode 100644 index 00000000..d917db5b --- /dev/null +++ b/adapter/neighbor.go @@ -0,0 +1,23 @@ +package adapter + +import ( + "net" + "net/netip" +) + +type NeighborEntry struct { + Address netip.Addr + MACAddress net.HardwareAddr + Hostname string +} + +type NeighborResolver interface { + LookupMAC(address netip.Addr) (net.HardwareAddr, bool) + LookupHostname(address netip.Addr) (string, bool) + Start() error + Close() error +} + +type NeighborUpdateListener interface { + UpdateNeighborTable(entries []NeighborEntry) +} diff --git a/adapter/network.go b/adapter/network.go new file mode 100644 index 00000000..dd53b2b4 --- /dev/null +++ b/adapter/network.go @@ -0,0 +1,60 @@ +package adapter + +import ( + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" +) + +type NetworkManager interface { + Lifecycle + Initialize(ruleSets []RuleSet) + InterfaceFinder() control.InterfaceFinder + UpdateInterfaces() error + DefaultNetworkInterface() *NetworkInterface + NetworkInterfaces() []NetworkInterface + AutoDetectInterface() bool + AutoDetectInterfaceFunc() control.Func + ProtectFunc() control.Func + DefaultOptions() NetworkOptions + RegisterAutoRedirectOutputMark(mark uint32) error + AutoRedirectOutputMark() uint32 + AutoRedirectOutputMarkFunc() control.Func + NetworkMonitor() tun.NetworkUpdateMonitor + InterfaceMonitor() tun.DefaultInterfaceMonitor + PackageManager() tun.PackageManager + NeedWIFIState() bool + WIFIState() WIFIState + UpdateWIFIState() + ResetNetwork() +} + +type NetworkOptions struct { + BindInterface string + RoutingMark uint32 + DomainResolver string + DomainResolveOptions DNSQueryOptions + NetworkStrategy *C.NetworkStrategy + NetworkType []C.InterfaceType + FallbackNetworkType []C.InterfaceType + FallbackDelay time.Duration +} + +type InterfaceUpdateListener interface { + InterfaceUpdated() +} + +type WIFIState struct { + SSID string + BSSID string +} + +type NetworkInterface struct { + control.Interface + Type C.InterfaceType + DNSServers []string + Expensive bool + Constrained bool +} diff --git a/adapter/outbound.go b/adapter/outbound.go new file mode 100644 index 00000000..91fb9c65 --- /dev/null +++ b/adapter/outbound.go @@ -0,0 +1,47 @@ +package adapter + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + N "github.com/sagernet/sing/common/network" +) + +// Note: for proxy protocols, outbound creates early connections by default. + +type Outbound interface { + Type() string + Tag() string + Network() []string + Dependencies() []string + N.Dialer +} + +type OutboundWithPreferredRoutes interface { + Outbound + PreferredDomain(domain string) bool + PreferredAddress(address netip.Addr) bool +} + +type DirectRouteOutbound interface { + Outbound + NewDirectRouteConnection(metadata InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) +} + +type OutboundRegistry interface { + option.OutboundOptionsRegistry + CreateOutbound(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) (Outbound, error) +} + +type OutboundManager interface { + Lifecycle + Outbounds() []Outbound + Outbound(tag string) (Outbound, bool) + Default() Outbound + Remove(tag string) error + Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error +} diff --git a/adapter/outbound/adapter.go b/adapter/outbound/adapter.go new file mode 100644 index 00000000..cd71527a --- /dev/null +++ b/adapter/outbound/adapter.go @@ -0,0 +1,45 @@ +package outbound + +import ( + "github.com/sagernet/sing-box/option" +) + +type Adapter struct { + outboundType string + outboundTag string + network []string + dependencies []string +} + +func NewAdapter(outboundType string, outboundTag string, network []string, dependencies []string) Adapter { + return Adapter{ + outboundType: outboundType, + outboundTag: outboundTag, + network: network, + dependencies: dependencies, + } +} + +func NewAdapterWithDialerOptions(outboundType string, outboundTag string, network []string, dialOptions option.DialerOptions) Adapter { + var dependencies []string + if dialOptions.Detour != "" { + dependencies = []string{dialOptions.Detour} + } + return NewAdapter(outboundType, outboundTag, network, dependencies) +} + +func (a *Adapter) Type() string { + return a.outboundType +} + +func (a *Adapter) Tag() string { + return a.outboundTag +} + +func (a *Adapter) Network() []string { + return a.network +} + +func (a *Adapter) Dependencies() []string { + return a.dependencies +} diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go new file mode 100644 index 00000000..5c1b5d99 --- /dev/null +++ b/adapter/outbound/manager.go @@ -0,0 +1,317 @@ +package outbound + +import ( + "context" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.OutboundManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.OutboundRegistry + endpoint adapter.EndpointManager + defaultTag string + access sync.RWMutex + started bool + stage adapter.StartStage + outbounds []adapter.Outbound + outboundByTag map[string]adapter.Outbound + dependByTag map[string][]string + defaultOutbound adapter.Outbound + defaultOutboundFallback func() (adapter.Outbound, error) +} + +func NewManager(logger logger.ContextLogger, registry adapter.OutboundRegistry, endpoint adapter.EndpointManager, defaultTag string) *Manager { + return &Manager{ + logger: logger, + registry: registry, + endpoint: endpoint, + defaultTag: defaultTag, + outboundByTag: make(map[string]adapter.Outbound), + dependByTag: make(map[string][]string), + } +} + +func (m *Manager) Initialize(defaultOutboundFallback func() (adapter.Outbound, error)) { + m.defaultOutboundFallback = defaultOutboundFallback +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + if stage == adapter.StartStateStart { + if m.defaultTag != "" && m.defaultOutbound == nil { + defaultEndpoint, loaded := m.endpoint.Get(m.defaultTag) + if !loaded { + m.access.Unlock() + return E.New("default outbound not found: ", m.defaultTag) + } + m.defaultOutbound = defaultEndpoint + } + if m.defaultOutbound == nil { + directOutbound, err := m.defaultOutboundFallback() + if err != nil { + m.access.Unlock() + return E.Cause(err, "create direct outbound for fallback") + } + m.outbounds = append(m.outbounds, directOutbound) + m.outboundByTag[directOutbound.Tag()] = directOutbound + m.defaultOutbound = directOutbound + } + outbounds := m.outbounds + m.access.Unlock() + return m.startOutbounds(append(outbounds, common.Map(m.endpoint.Endpoints(), func(it adapter.Endpoint) adapter.Outbound { return it })...)) + } else { + outbounds := m.outbounds + m.access.Unlock() + for _, outbound := range outbounds { + name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err := adapter.LegacyStart(outbound, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + return nil +} + +func (m *Manager) startOutbounds(outbounds []adapter.Outbound) error { + monitor := taskmonitor.New(m.logger, C.StartTimeout) + started := make(map[string]bool) + for { + canContinue := false + startOne: + for _, outboundToStart := range outbounds { + outboundTag := outboundToStart.Tag() + if started[outboundTag] { + continue + } + dependencies := outboundToStart.Dependencies() + for _, dependency := range dependencies { + if !started[dependency] { + continue startOne + } + } + started[outboundTag] = true + canContinue = true + name := "outbound/" + outboundToStart.Type() + "[" + outboundTag + "]" + if starter, isStarter := outboundToStart.(adapter.Lifecycle); isStarter { + m.logger.Trace("start ", name) + startTime := time.Now() + monitor.Start("start ", name) + err := starter.Start(adapter.StartStateStart) + monitor.Finish() + if err != nil { + return E.Cause(err, "start ", name) + } + m.logger.Trace("start ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } else if starter, isStarter := outboundToStart.(interface { + Start() error + }); isStarter { + m.logger.Trace("start ", name) + startTime := time.Now() + monitor.Start("start ", name) + err := starter.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "start ", name) + } + m.logger.Trace("start ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + if len(started) == len(outbounds) { + break + } + if canContinue { + continue + } + currentOutbound := common.Find(outbounds, func(it adapter.Outbound) bool { + return !started[it.Tag()] + }) + var lintOutbound func(oTree []string, oCurrent adapter.Outbound) error + lintOutbound = func(oTree []string, oCurrent adapter.Outbound) error { + problemOutboundTag := common.Find(oCurrent.Dependencies(), func(it string) bool { + return !started[it] + }) + if common.Contains(oTree, problemOutboundTag) { + return E.New("circular outbound dependency: ", strings.Join(oTree, " -> "), " -> ", problemOutboundTag) + } + m.access.Lock() + problemOutbound := m.outboundByTag[problemOutboundTag] + m.access.Unlock() + if problemOutbound == nil { + return E.New("dependency[", problemOutboundTag, "] not found for outbound[", oCurrent.Tag(), "]") + } + return lintOutbound(append(oTree, problemOutboundTag), problemOutbound) + } + return lintOutbound([]string{currentOutbound.Tag()}, currentOutbound) + } + return nil +} + +func (m *Manager) Close() error { + monitor := taskmonitor.New(m.logger, C.StopTimeout) + m.access.Lock() + if !m.started { + m.access.Unlock() + return nil + } + m.started = false + outbounds := m.outbounds + m.outbounds = nil + m.access.Unlock() + var err error + for _, outbound := range outbounds { + if closer, isCloser := outbound.(io.Closer); isCloser { + name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + m.logger.Trace("close ", name) + startTime := time.Now() + monitor.Start("close ", name) + err = E.Append(err, closer.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + monitor.Finish() + m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + return nil +} + +func (m *Manager) Outbounds() []adapter.Outbound { + m.access.RLock() + defer m.access.RUnlock() + return m.outbounds +} + +func (m *Manager) Outbound(tag string) (adapter.Outbound, bool) { + m.access.RLock() + outbound, found := m.outboundByTag[tag] + m.access.RUnlock() + if found { + return outbound, true + } + return m.endpoint.Get(tag) +} + +func (m *Manager) Default() adapter.Outbound { + m.access.RLock() + defer m.access.RUnlock() + return m.defaultOutbound +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + defer m.access.Unlock() + outbound, found := m.outboundByTag[tag] + if !found { + return os.ErrInvalid + } + delete(m.outboundByTag, tag) + index := common.Index(m.outbounds, func(it adapter.Outbound) bool { + return it == outbound + }) + if index == -1 { + panic("invalid inbound index") + } + m.outbounds = append(m.outbounds[:index], m.outbounds[index+1:]...) + started := m.started + if m.defaultOutbound == outbound { + if len(m.outbounds) > 0 { + m.defaultOutbound = m.outbounds[0] + m.logger.Info("updated default outbound to ", m.defaultOutbound.Tag()) + } else { + m.defaultOutbound = nil + } + } + dependBy := m.dependByTag[tag] + if len(dependBy) > 0 { + return E.New("outbound[", tag, "] is depended by ", strings.Join(dependBy, ", ")) + } + dependencies := outbound.Dependencies() + for _, dependency := range dependencies { + if len(m.dependByTag[dependency]) == 1 { + delete(m.dependByTag, dependency) + } else { + m.dependByTag[dependency] = common.Filter(m.dependByTag[dependency], func(it string) bool { + return it != tag + }) + } + } + if started { + return common.Close(outbound) + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, inboundType string, options any) error { + if tag == "" { + return os.ErrInvalid + } + outbound, err := m.registry.CreateOutbound(ctx, router, logger, tag, inboundType, options) + if err != nil { + return err + } + if m.started { + name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + for _, stage := range adapter.ListStartStages { + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err = adapter.LegacyStart(outbound, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + m.access.Lock() + defer m.access.Unlock() + if existsOutbound, loaded := m.outboundByTag[tag]; loaded { + if m.started { + err = common.Close(existsOutbound) + if err != nil { + return E.Cause(err, "close outbound/", existsOutbound.Type(), "[", existsOutbound.Tag(), "]") + } + } + existsIndex := common.Index(m.outbounds, func(it adapter.Outbound) bool { + return it == existsOutbound + }) + if existsIndex == -1 { + panic("invalid inbound index") + } + m.outbounds = append(m.outbounds[:existsIndex], m.outbounds[existsIndex+1:]...) + } + m.outbounds = append(m.outbounds, outbound) + m.outboundByTag[tag] = outbound + dependencies := outbound.Dependencies() + for _, dependency := range dependencies { + m.dependByTag[dependency] = append(m.dependByTag[dependency], tag) + } + if tag == m.defaultTag || (m.defaultTag == "" && m.defaultOutbound == nil) { + m.defaultOutbound = outbound + if m.started { + m.logger.Info("updated default outbound to ", outbound.Tag()) + } + } + return nil +} diff --git a/adapter/outbound/registry.go b/adapter/outbound/registry.go new file mode 100644 index 00000000..8743ba10 --- /dev/null +++ b/adapter/outbound/registry.go @@ -0,0 +1,72 @@ +package outbound + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options T) (adapter.Outbound, error) + +func Register[Options any](registry *Registry, outboundType string, constructor ConstructorFunc[Options]) { + registry.register(outboundType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, rawOptions any) (adapter.Outbound, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.OutboundRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options any) (adapter.Outbound, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructors map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructors: make(map[string]constructorFunc), + } +} + +func (r *Registry) CreateOptions(outboundType string) (any, bool) { + r.access.Lock() + defer r.access.Unlock() + optionsConstructor, loaded := r.optionsType[outboundType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (r *Registry) CreateOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Outbound, error) { + r.access.Lock() + defer r.access.Unlock() + constructor, loaded := r.constructors[outboundType] + if !loaded { + return nil, E.New("outbound type not found: " + outboundType) + } + return constructor(ctx, router, logger, tag, options) +} + +func (r *Registry) register(outboundType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + r.access.Lock() + defer r.access.Unlock() + r.optionsType[outboundType] = optionsConstructor + r.constructors[outboundType] = constructor +} diff --git a/adapter/platform.go b/adapter/platform.go new file mode 100644 index 00000000..fd966548 --- /dev/null +++ b/adapter/platform.go @@ -0,0 +1,74 @@ +package adapter + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" +) + +type PlatformInterface interface { + Initialize(networkManager NetworkManager) error + + UsePlatformAutoDetectInterfaceControl() bool + AutoDetectInterfaceControl(fd int) error + + UsePlatformInterface() bool + OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) + + UsePlatformDefaultInterfaceMonitor() bool + CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor + + UsePlatformNetworkInterfaces() bool + NetworkInterfaces() ([]NetworkInterface, error) + + UnderNetworkExtension() bool + NetworkExtensionIncludeAllNetworks() bool + + ClearDNSCache() + RequestPermissionForWIFIState() error + ReadWIFIState() WIFIState + SystemCertificates() []string + + UsePlatformConnectionOwnerFinder() bool + FindConnectionOwner(request *FindConnectionOwnerRequest) (*ConnectionOwner, error) + + UsePlatformWIFIMonitor() bool + + UsePlatformNotification() bool + SendNotification(notification *Notification) error + + UsePlatformNeighborResolver() bool + StartNeighborMonitor(listener NeighborUpdateListener) error + CloseNeighborMonitor(listener NeighborUpdateListener) error +} + +type FindConnectionOwnerRequest struct { + IpProtocol int32 + SourceAddress string + SourcePort int32 + DestinationAddress string + DestinationPort int32 +} + +type ConnectionOwner struct { + ProcessID uint32 + UserId int32 + UserName string + ProcessPath string + AndroidPackageNames []string +} + +type Notification struct { + Identifier string + TypeName string + TypeID int32 + Title string + Subtitle string + Body string + OpenURL string +} + +type SystemProxyStatus struct { + Available bool + Enabled bool +} diff --git a/adapter/prestart.go b/adapter/prestart.go new file mode 100644 index 00000000..b8e8da30 --- /dev/null +++ b/adapter/prestart.go @@ -0,0 +1 @@ +package adapter diff --git a/adapter/router.go b/adapter/router.go new file mode 100644 index 00000000..f1e3da9a --- /dev/null +++ b/adapter/router.go @@ -0,0 +1,122 @@ +package adapter + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "sync" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-tun" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/x/list" + + "go4.org/netipx" +) + +type Router interface { + Lifecycle + ConnectionRouter + PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration, supportBypass bool) (tun.DirectRouteDestination, error) + ConnectionRouterEx + RuleSet(tag string) (RuleSet, bool) + Rules() []Rule + NeedFindProcess() bool + NeedFindNeighbor() bool + NeighborResolver() NeighborResolver + AppendTracker(tracker ConnectionTracker) + ResetNetwork() +} + +type ConnectionTracker interface { + RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) net.Conn + RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) N.PacketConn +} + +// Deprecated: Use ConnectionRouterEx instead. +type ConnectionRouter interface { + RouteConnection(ctx context.Context, conn net.Conn, metadata InboundContext) error + RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext) error +} + +type ConnectionRouterEx interface { + ConnectionRouter + RouteConnectionEx(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc) + RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc) +} + +type RuleSet interface { + Name() string + StartContext(ctx context.Context, startContext *HTTPStartContext) error + PostStart() error + Metadata() RuleSetMetadata + ExtractIPSet() []*netipx.IPSet + IncRef() + DecRef() + Cleanup() + RegisterCallback(callback RuleSetUpdateCallback) *list.Element[RuleSetUpdateCallback] + UnregisterCallback(element *list.Element[RuleSetUpdateCallback]) + Close() error + HeadlessRule +} + +type RuleSetUpdateCallback func(it RuleSet) + +type DNSRuleSetUpdateValidator interface { + ValidateRuleSetMetadataUpdate(tag string, metadata RuleSetMetadata) error +} + +// ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. +type RuleSetMetadata struct { + ContainsProcessRule bool + ContainsWIFIRule bool + ContainsIPCIDRRule bool + ContainsDNSQueryTypeRule bool +} +type HTTPStartContext struct { + ctx context.Context + access sync.Mutex + httpClientCache map[string]*http.Client +} + +func NewHTTPStartContext(ctx context.Context) *HTTPStartContext { + return &HTTPStartContext{ + ctx: ctx, + httpClientCache: make(map[string]*http.Client), + } +} + +func (c *HTTPStartContext) HTTPClient(detour string, dialer N.Dialer) *http.Client { + c.access.Lock() + defer c.access.Unlock() + if httpClient, loaded := c.httpClientCache[detour]; loaded { + return httpClient + } + httpClient := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(c.ctx), + RootCAs: RootPoolFromContext(c.ctx), + }, + }, + } + c.httpClientCache[detour] = httpClient + return httpClient +} + +func (c *HTTPStartContext) Close() { + c.access.Lock() + defer c.access.Unlock() + for _, client := range c.httpClientCache { + client.CloseIdleConnections() + } +} diff --git a/adapter/rule.go b/adapter/rule.go new file mode 100644 index 00000000..2117ba45 --- /dev/null +++ b/adapter/rule.go @@ -0,0 +1,40 @@ +package adapter + +import ( + C "github.com/sagernet/sing-box/constant" + + "github.com/miekg/dns" +) + +type HeadlessRule interface { + Match(metadata *InboundContext) bool + String() string +} + +type Rule interface { + HeadlessRule + SimpleLifecycle + Type() string + Action() RuleAction +} + +type DNSRule interface { + Rule + LegacyPreMatch(metadata *InboundContext) bool + WithAddressLimit() bool + MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool +} + +type RuleAction interface { + Type() string + String() string +} + +func IsFinalAction(action RuleAction) bool { + switch action.Type() { + case C.RuleActionTypeSniff, C.RuleActionTypeResolve, C.RuleActionTypeEvaluate: + return false + default: + return true + } +} diff --git a/adapter/service.go b/adapter/service.go new file mode 100644 index 00000000..534bd7eb --- /dev/null +++ b/adapter/service.go @@ -0,0 +1,27 @@ +package adapter + +import ( + "context" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +type Service interface { + Lifecycle + Type() string + Tag() string +} + +type ServiceRegistry interface { + option.ServiceOptionsRegistry + Create(ctx context.Context, logger log.ContextLogger, tag string, serviceType string, options any) (Service, error) +} + +type ServiceManager interface { + Lifecycle + Services() []Service + Get(tag string) (Service, bool) + Remove(tag string) error + Create(ctx context.Context, logger log.ContextLogger, tag string, serviceType string, options any) error +} diff --git a/adapter/service/adapter.go b/adapter/service/adapter.go new file mode 100644 index 00000000..6c6242ea --- /dev/null +++ b/adapter/service/adapter.go @@ -0,0 +1,21 @@ +package service + +type Adapter struct { + serviceType string + serviceTag string +} + +func NewAdapter(serviceType string, serviceTag string) Adapter { + return Adapter{ + serviceType: serviceType, + serviceTag: serviceTag, + } +} + +func (a *Adapter) Type() string { + return a.serviceType +} + +func (a *Adapter) Tag() string { + return a.serviceTag +} diff --git a/adapter/service/manager.go b/adapter/service/manager.go new file mode 100644 index 00000000..f17aa07e --- /dev/null +++ b/adapter/service/manager.go @@ -0,0 +1,158 @@ +package service + +import ( + "context" + "os" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ adapter.ServiceManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.ServiceRegistry + access sync.Mutex + started bool + stage adapter.StartStage + services []adapter.Service + serviceByTag map[string]adapter.Service +} + +func NewManager(logger log.ContextLogger, registry adapter.ServiceRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + serviceByTag: make(map[string]adapter.Service), + } +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + services := m.services + m.access.Unlock() + for _, service := range services { + name := "service/" + service.Type() + "[" + service.Tag() + "]" + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err := adapter.LegacyStart(service, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Close() error { + m.access.Lock() + defer m.access.Unlock() + if !m.started { + return nil + } + m.started = false + services := m.services + m.services = nil + monitor := taskmonitor.New(m.logger, C.StopTimeout) + var err error + for _, service := range services { + name := "service/" + service.Type() + "[" + service.Tag() + "]" + m.logger.Trace("close ", name) + startTime := time.Now() + monitor.Start("close ", name) + err = E.Append(err, service.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + monitor.Finish() + m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Services() []adapter.Service { + m.access.Lock() + defer m.access.Unlock() + return m.services +} + +func (m *Manager) Get(tag string) (adapter.Service, bool) { + m.access.Lock() + service, found := m.serviceByTag[tag] + m.access.Unlock() + return service, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + service, found := m.serviceByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.serviceByTag, tag) + index := common.Index(m.services, func(it adapter.Service) bool { + return it == service + }) + if index == -1 { + panic("invalid service index") + } + m.services = append(m.services[:index], m.services[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return service.Close() + } + return nil +} + +func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, serviceType string, options any) error { + service, err := m.registry.Create(ctx, logger, tag, serviceType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + name := "service/" + service.Type() + "[" + service.Tag() + "]" + for _, stage := range adapter.ListStartStages { + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err = adapter.LegacyStart(service, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + if existsService, loaded := m.serviceByTag[tag]; loaded { + if m.started { + err = existsService.Close() + if err != nil { + return E.Cause(err, "close service/", existsService.Type(), "[", existsService.Tag(), "]") + } + } + existsIndex := common.Index(m.services, func(it adapter.Service) bool { + return it == existsService + }) + if existsIndex == -1 { + panic("invalid service index") + } + m.services = append(m.services[:existsIndex], m.services[existsIndex+1:]...) + } + m.services = append(m.services, service) + m.serviceByTag[tag] = service + return nil +} diff --git a/adapter/service/registry.go b/adapter/service/registry.go new file mode 100644 index 00000000..42fec82f --- /dev/null +++ b/adapter/service/registry.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, logger log.ContextLogger, tag string, options T) (adapter.Service, error) + +func Register[Options any](registry *Registry, outboundType string, constructor ConstructorFunc[Options]) { + registry.register(outboundType, func() any { + return new(Options) + }, func(ctx context.Context, logger log.ContextLogger, tag string, rawOptions any) (adapter.Service, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.ServiceRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, logger log.ContextLogger, tag string, options any) (adapter.Service, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructor map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructor: make(map[string]constructorFunc), + } +} + +func (m *Registry) CreateOptions(outboundType string) (any, bool) { + m.access.Lock() + defer m.access.Unlock() + optionsConstructor, loaded := m.optionsType[outboundType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (m *Registry) Create(ctx context.Context, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Service, error) { + m.access.Lock() + defer m.access.Unlock() + constructor, loaded := m.constructor[outboundType] + if !loaded { + return nil, E.New("outbound type not found: " + outboundType) + } + return constructor(ctx, logger, tag, options) +} + +func (m *Registry) register(outboundType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + m.access.Lock() + defer m.access.Unlock() + m.optionsType[outboundType] = optionsConstructor + m.constructor[outboundType] = constructor +} diff --git a/adapter/ssm.go b/adapter/ssm.go new file mode 100644 index 00000000..caab9221 --- /dev/null +++ b/adapter/ssm.go @@ -0,0 +1,18 @@ +package adapter + +import ( + "net" + + N "github.com/sagernet/sing/common/network" +) + +type ManagedSSMServer interface { + Inbound + SetTracker(tracker SSMTracker) + UpdateUsers(users []string, uPSKs []string) error +} + +type SSMTracker interface { + TrackConnection(conn net.Conn, metadata InboundContext) net.Conn + TrackPacketConnection(conn N.PacketConn, metadata InboundContext) N.PacketConn +} diff --git a/adapter/tailscale.go b/adapter/tailscale.go new file mode 100644 index 00000000..22f48e62 --- /dev/null +++ b/adapter/tailscale.go @@ -0,0 +1,49 @@ +package adapter + +import "context" + +type TailscaleEndpoint interface { + SubscribeTailscaleStatus(ctx context.Context, fn func(*TailscaleEndpointStatus)) error + StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error +} + +type TailscalePingResult struct { + LatencyMs float64 + IsDirect bool + Endpoint string + DERPRegionID int32 + DERPRegionCode string + Error string +} + +type TailscaleEndpointStatus struct { + BackendState string + AuthURL string + NetworkName string + MagicDNSSuffix string + Self *TailscalePeer + UserGroups []*TailscaleUserGroup +} + +type TailscaleUserGroup struct { + UserID int64 + LoginName string + DisplayName string + ProfilePicURL string + Peers []*TailscalePeer +} + +type TailscalePeer struct { + HostName string + DNSName string + OS string + TailscaleIPs []string + Online bool + ExitNode bool + ExitNodeOption bool + Active bool + RxBytes int64 + TxBytes int64 + UserID int64 + KeyExpiry int64 +} diff --git a/adapter/time.go b/adapter/time.go new file mode 100644 index 00000000..be2631d8 --- /dev/null +++ b/adapter/time.go @@ -0,0 +1,8 @@ +package adapter + +import "time" + +type TimeService interface { + SimpleLifecycle + TimeFunc() func() time.Time +} diff --git a/adapter/upstream.go b/adapter/upstream.go new file mode 100644 index 00000000..59c8f75f --- /dev/null +++ b/adapter/upstream.go @@ -0,0 +1,168 @@ +package adapter + +import ( + "context" + "net" + + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type ( + ConnectionHandlerFuncEx = func(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc) + PacketConnectionHandlerFuncEx = func(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc) +) + +func NewUpstreamHandlerEx( + metadata InboundContext, + connectionHandler ConnectionHandlerFuncEx, + packetHandler PacketConnectionHandlerFuncEx, +) UpstreamHandlerAdapterEx { + return &myUpstreamHandlerWrapperEx{ + metadata: metadata, + connectionHandler: connectionHandler, + packetHandler: packetHandler, + } +} + +var _ UpstreamHandlerAdapterEx = (*myUpstreamHandlerWrapperEx)(nil) + +type myUpstreamHandlerWrapperEx struct { + metadata InboundContext + connectionHandler ConnectionHandlerFuncEx + packetHandler PacketConnectionHandlerFuncEx +} + +func (w *myUpstreamHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + myMetadata := w.metadata + if source.IsValid() { + myMetadata.Source = source + } + if destination.IsValid() { + myMetadata.Destination = destination + } + w.connectionHandler(ctx, conn, myMetadata, onClose) +} + +func (w *myUpstreamHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + myMetadata := w.metadata + if source.IsValid() { + myMetadata.Source = source + } + if destination.IsValid() { + myMetadata.Destination = destination + } + w.packetHandler(ctx, conn, myMetadata, onClose) +} + +var _ UpstreamHandlerAdapterEx = (*myUpstreamContextHandlerWrapperEx)(nil) + +type myUpstreamContextHandlerWrapperEx struct { + connectionHandler ConnectionHandlerFuncEx + packetHandler PacketConnectionHandlerFuncEx +} + +func NewUpstreamContextHandlerEx( + connectionHandler ConnectionHandlerFuncEx, + packetHandler PacketConnectionHandlerFuncEx, +) UpstreamHandlerAdapterEx { + return &myUpstreamContextHandlerWrapperEx{ + connectionHandler: connectionHandler, + packetHandler: packetHandler, + } +} + +func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + _, myMetadata := ExtendContext(ctx) + if source.IsValid() { + myMetadata.Source = source + } + if destination.IsValid() { + myMetadata.Destination = destination + } + w.connectionHandler(ctx, conn, *myMetadata, onClose) +} + +func (w *myUpstreamContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + _, myMetadata := ExtendContext(ctx) + if source.IsValid() { + myMetadata.Source = source + } + if destination.IsValid() { + myMetadata.Destination = destination + } + w.packetHandler(ctx, conn, *myMetadata, onClose) +} + +func NewRouteHandlerEx( + metadata InboundContext, + router ConnectionRouterEx, +) UpstreamHandlerAdapterEx { + return &routeHandlerWrapperEx{ + metadata: metadata, + router: router, + } +} + +var _ UpstreamHandlerAdapterEx = (*routeHandlerWrapperEx)(nil) + +type routeHandlerWrapperEx struct { + metadata InboundContext + router ConnectionRouterEx +} + +func (r *routeHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + if source.IsValid() { + r.metadata.Source = source + } + if destination.IsValid() { + r.metadata.Destination = destination + } + r.router.RouteConnectionEx(ctx, conn, r.metadata, onClose) +} + +func (r *routeHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + if source.IsValid() { + r.metadata.Source = source + } + if destination.IsValid() { + r.metadata.Destination = destination + } + r.router.RoutePacketConnectionEx(ctx, conn, r.metadata, onClose) +} + +func NewRouteContextHandlerEx( + router ConnectionRouterEx, +) UpstreamHandlerAdapterEx { + return &routeContextHandlerWrapperEx{ + router: router, + } +} + +var _ UpstreamHandlerAdapterEx = (*routeContextHandlerWrapperEx)(nil) + +type routeContextHandlerWrapperEx struct { + router ConnectionRouterEx +} + +func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + _, metadata := ExtendContext(ctx) + if source.IsValid() { + metadata.Source = source + } + if destination.IsValid() { + metadata.Destination = destination + } + r.router.RouteConnectionEx(ctx, conn, *metadata, onClose) +} + +func (r *routeContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + _, metadata := ExtendContext(ctx) + if source.IsValid() { + metadata.Source = source + } + if destination.IsValid() { + metadata.Destination = destination + } + r.router.RoutePacketConnectionEx(ctx, conn, *metadata, onClose) +} diff --git a/adapter/upstream_legacy.go b/adapter/upstream_legacy.go new file mode 100644 index 00000000..65402563 --- /dev/null +++ b/adapter/upstream_legacy.go @@ -0,0 +1,234 @@ +package adapter + +import ( + "context" + "net" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type ( + // Deprecated + ConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext) error + // Deprecated + PacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext) error +) + +// Deprecated +// +//nolint:staticcheck +func NewUpstreamHandler( + metadata InboundContext, + connectionHandler ConnectionHandlerFunc, + packetHandler PacketConnectionHandlerFunc, + errorHandler E.Handler, +) UpstreamHandlerAdapter { + return &myUpstreamHandlerWrapper{ + metadata: metadata, + connectionHandler: connectionHandler, + packetHandler: packetHandler, + errorHandler: errorHandler, + } +} + +var _ UpstreamHandlerAdapter = (*myUpstreamHandlerWrapper)(nil) + +// Deprecated: use myUpstreamHandlerWrapperEx instead. +// +//nolint:staticcheck +type myUpstreamHandlerWrapper struct { + metadata InboundContext + connectionHandler ConnectionHandlerFunc + packetHandler PacketConnectionHandlerFunc + errorHandler E.Handler +} + +// Deprecated: use myUpstreamHandlerWrapperEx instead. +func (w *myUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { + myMetadata := w.metadata + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.connectionHandler(ctx, conn, myMetadata) +} + +// Deprecated: use myUpstreamHandlerWrapperEx instead. +func (w *myUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { + myMetadata := w.metadata + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.packetHandler(ctx, conn, myMetadata) +} + +// Deprecated: use myUpstreamHandlerWrapperEx instead. +func (w *myUpstreamHandlerWrapper) NewError(ctx context.Context, err error) { + w.errorHandler.NewError(ctx, err) +} + +// Deprecated: removed +func UpstreamMetadata(metadata InboundContext) M.Metadata { + return M.Metadata{ + Source: metadata.Source.Unwrap(), + Destination: metadata.Destination.Unwrap(), + } +} + +// Deprecated: Use NewUpstreamContextHandlerEx instead. +type myUpstreamContextHandlerWrapper struct { + connectionHandler ConnectionHandlerFunc + packetHandler PacketConnectionHandlerFunc + errorHandler E.Handler +} + +// Deprecated: Use NewUpstreamContextHandlerEx instead. +func NewUpstreamContextHandler( + connectionHandler ConnectionHandlerFunc, + packetHandler PacketConnectionHandlerFunc, + errorHandler E.Handler, +) UpstreamHandlerAdapter { + return &myUpstreamContextHandlerWrapper{ + connectionHandler: connectionHandler, + packetHandler: packetHandler, + errorHandler: errorHandler, + } +} + +// Deprecated: Use NewUpstreamContextHandlerEx instead. +func (w *myUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { + myMetadata := ContextFrom(ctx) + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.connectionHandler(ctx, conn, *myMetadata) +} + +// Deprecated: Use NewUpstreamContextHandlerEx instead. +func (w *myUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { + myMetadata := ContextFrom(ctx) + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.packetHandler(ctx, conn, *myMetadata) +} + +// Deprecated: Use NewUpstreamContextHandlerEx instead. +func (w *myUpstreamContextHandlerWrapper) NewError(ctx context.Context, err error) { + w.errorHandler.NewError(ctx, err) +} + +// Deprecated: Use ConnectionRouterEx instead. +func NewRouteHandler( + metadata InboundContext, + router ConnectionRouter, + logger logger.ContextLogger, +) UpstreamHandlerAdapter { + return &routeHandlerWrapper{ + metadata: metadata, + router: router, + logger: logger, + } +} + +// Deprecated: Use ConnectionRouterEx instead. +func NewRouteContextHandler( + router ConnectionRouter, + logger logger.ContextLogger, +) UpstreamHandlerAdapter { + return &routeContextHandlerWrapper{ + router: router, + logger: logger, + } +} + +var _ UpstreamHandlerAdapter = (*routeHandlerWrapper)(nil) + +// Deprecated: Use ConnectionRouterEx instead. +// +//nolint:staticcheck +type routeHandlerWrapper struct { + metadata InboundContext + router ConnectionRouter + logger logger.ContextLogger +} + +// Deprecated: Use ConnectionRouterEx instead. +func (w *routeHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { + myMetadata := w.metadata + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.router.RouteConnection(ctx, conn, myMetadata) +} + +// Deprecated: Use ConnectionRouterEx instead. +func (w *routeHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { + myMetadata := w.metadata + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.router.RoutePacketConnection(ctx, conn, myMetadata) +} + +// Deprecated: Use ConnectionRouterEx instead. +func (w *routeHandlerWrapper) NewError(ctx context.Context, err error) { + w.logger.ErrorContext(ctx, err) +} + +var _ UpstreamHandlerAdapter = (*routeContextHandlerWrapper)(nil) + +// Deprecated: Use ConnectionRouterEx instead. +type routeContextHandlerWrapper struct { + router ConnectionRouter + logger logger.ContextLogger +} + +// Deprecated: Use ConnectionRouterEx instead. +func (w *routeContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { + myMetadata := ContextFrom(ctx) + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.router.RouteConnection(ctx, conn, *myMetadata) +} + +// Deprecated: Use ConnectionRouterEx instead. +func (w *routeContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { + myMetadata := ContextFrom(ctx) + if metadata.Source.IsValid() { + myMetadata.Source = metadata.Source + } + if metadata.Destination.IsValid() { + myMetadata.Destination = metadata.Destination + } + return w.router.RoutePacketConnection(ctx, conn, *myMetadata) +} + +// Deprecated: Use ConnectionRouterEx instead. +func (w *routeContextHandlerWrapper) NewError(ctx context.Context, err error) { + w.logger.ErrorContext(ctx, err) +} diff --git a/adapter/v2ray.go b/adapter/v2ray.go new file mode 100644 index 00000000..d9370807 --- /dev/null +++ b/adapter/v2ray.go @@ -0,0 +1,24 @@ +package adapter + +import ( + "context" + "net" + + N "github.com/sagernet/sing/common/network" +) + +type V2RayServerTransport interface { + Network() []string + Serve(listener net.Listener) error + ServePacket(listener net.PacketConn) error + Close() error +} + +type V2RayServerTransportHandler interface { + N.TCPConnectionHandlerEx +} + +type V2RayClientTransport interface { + DialContext(ctx context.Context) (net.Conn, error) + Close() error +} diff --git a/box.go b/box.go new file mode 100644 index 00000000..b4844f9e --- /dev/null +++ b/box.go @@ -0,0 +1,609 @@ +package box + +import ( + "context" + "fmt" + "io" + "os" + "runtime/debug" + "time" + + "github.com/sagernet/sing-box/adapter" + boxCertificate "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/certificate" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/taskmonitor" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/experimental/cachefile" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/direct" + "github.com/sagernet/sing-box/route" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" +) + +var _ adapter.SimpleLifecycle = (*Box)(nil) + +type Box struct { + createdAt time.Time + logFactory log.Factory + logger log.ContextLogger + network *route.NetworkManager + endpoint *endpoint.Manager + inbound *inbound.Manager + outbound *outbound.Manager + service *boxService.Manager + certificateProvider *boxCertificate.Manager + dnsTransport *dns.TransportManager + dnsRouter *dns.Router + connection *route.ConnectionManager + router *route.Router + internalService []adapter.LifecycleService + done chan struct{} +} + +type Options struct { + option.Options + Context context.Context + PlatformLogWriter log.PlatformWriter +} + +func Context( + ctx context.Context, + inboundRegistry adapter.InboundRegistry, + outboundRegistry adapter.OutboundRegistry, + endpointRegistry adapter.EndpointRegistry, + dnsTransportRegistry adapter.DNSTransportRegistry, + serviceRegistry adapter.ServiceRegistry, + certificateProviderRegistry adapter.CertificateProviderRegistry, +) context.Context { + if service.FromContext[option.InboundOptionsRegistry](ctx) == nil || + service.FromContext[adapter.InboundRegistry](ctx) == nil { + ctx = service.ContextWith[option.InboundOptionsRegistry](ctx, inboundRegistry) + ctx = service.ContextWith[adapter.InboundRegistry](ctx, inboundRegistry) + } + if service.FromContext[option.OutboundOptionsRegistry](ctx) == nil || + service.FromContext[adapter.OutboundRegistry](ctx) == nil { + ctx = service.ContextWith[option.OutboundOptionsRegistry](ctx, outboundRegistry) + ctx = service.ContextWith[adapter.OutboundRegistry](ctx, outboundRegistry) + } + if service.FromContext[option.EndpointOptionsRegistry](ctx) == nil || + service.FromContext[adapter.EndpointRegistry](ctx) == nil { + ctx = service.ContextWith[option.EndpointOptionsRegistry](ctx, endpointRegistry) + ctx = service.ContextWith[adapter.EndpointRegistry](ctx, endpointRegistry) + } + if service.FromContext[adapter.DNSTransportRegistry](ctx) == nil { + ctx = service.ContextWith[option.DNSTransportOptionsRegistry](ctx, dnsTransportRegistry) + ctx = service.ContextWith[adapter.DNSTransportRegistry](ctx, dnsTransportRegistry) + } + if service.FromContext[adapter.ServiceRegistry](ctx) == nil { + ctx = service.ContextWith[option.ServiceOptionsRegistry](ctx, serviceRegistry) + ctx = service.ContextWith[adapter.ServiceRegistry](ctx, serviceRegistry) + } + if service.FromContext[adapter.CertificateProviderRegistry](ctx) == nil { + ctx = service.ContextWith[option.CertificateProviderOptionsRegistry](ctx, certificateProviderRegistry) + ctx = service.ContextWith[adapter.CertificateProviderRegistry](ctx, certificateProviderRegistry) + } + return ctx +} + +func New(options Options) (*Box, error) { + createdAt := time.Now() + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + ctx = service.ContextWithDefaultRegistry(ctx) + + endpointRegistry := service.FromContext[adapter.EndpointRegistry](ctx) + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) + serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) + certificateProviderRegistry := service.FromContext[adapter.CertificateProviderRegistry](ctx) + + if endpointRegistry == nil { + return nil, E.New("missing endpoint registry in context") + } + if inboundRegistry == nil { + return nil, E.New("missing inbound registry in context") + } + if outboundRegistry == nil { + return nil, E.New("missing outbound registry in context") + } + if dnsTransportRegistry == nil { + return nil, E.New("missing DNS transport registry in context") + } + if serviceRegistry == nil { + return nil, E.New("missing service registry in context") + } + if certificateProviderRegistry == nil { + return nil, E.New("missing certificate provider registry in context") + } + + ctx = pause.WithDefaultManager(ctx) + experimentalOptions := common.PtrValueOrDefault(options.Experimental) + err := applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug)) + if err != nil { + return nil, err + } + var needCacheFile bool + var needClashAPI bool + var needV2RayAPI bool + if experimentalOptions.CacheFile != nil && experimentalOptions.CacheFile.Enabled || options.PlatformLogWriter != nil { + needCacheFile = true + } + if experimentalOptions.ClashAPI != nil || options.PlatformLogWriter != nil { + needClashAPI = true + } + if experimentalOptions.V2RayAPI != nil && experimentalOptions.V2RayAPI.Listen != "" { + needV2RayAPI = true + } + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + var defaultLogWriter io.Writer + if platformInterface != nil { + defaultLogWriter = io.Discard + } + logFactory, err := log.New(log.Options{ + Context: ctx, + Options: common.PtrValueOrDefault(options.Log), + Observable: needClashAPI, + DefaultWriter: defaultLogWriter, + BaseTime: createdAt, + PlatformWriter: options.PlatformLogWriter, + }) + if err != nil { + return nil, E.Cause(err, "create log factory") + } + + var internalServices []adapter.LifecycleService + certificateOptions := common.PtrValueOrDefault(options.Certificate) + if C.IsAndroid || certificateOptions.Store != "" && certificateOptions.Store != C.CertificateStoreSystem || + len(certificateOptions.Certificate) > 0 || + len(certificateOptions.CertificatePath) > 0 || + len(certificateOptions.CertificateDirectoryPath) > 0 { + certificateStore, err := certificate.NewStore(ctx, logFactory.NewLogger("certificate"), certificateOptions) + if err != nil { + return nil, err + } + service.MustRegister[adapter.CertificateStore](ctx, certificateStore) + internalServices = append(internalServices, certificateStore) + } + + routeOptions := common.PtrValueOrDefault(options.Route) + dnsOptions := common.PtrValueOrDefault(options.DNS) + endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry) + inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager) + outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) + dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) + serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) + certificateProviderManager := boxCertificate.NewManager(logFactory.NewLogger("certificate-provider"), certificateProviderRegistry) + service.MustRegister[adapter.EndpointManager](ctx, endpointManager) + service.MustRegister[adapter.InboundManager](ctx, inboundManager) + service.MustRegister[adapter.OutboundManager](ctx, outboundManager) + service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) + service.MustRegister[adapter.ServiceManager](ctx, serviceManager) + service.MustRegister[adapter.CertificateProviderManager](ctx, certificateProviderManager) + dnsRouter, err := dns.NewRouter(ctx, logFactory, dnsOptions) + if err != nil { + return nil, E.Cause(err, "initialize DNS router") + } + service.MustRegister[adapter.DNSRouter](ctx, dnsRouter) + service.MustRegister[adapter.DNSRuleSetUpdateValidator](ctx, dnsRouter) + networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions) + if err != nil { + return nil, E.Cause(err, "initialize network manager") + } + service.MustRegister[adapter.NetworkManager](ctx, networkManager) + connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection")) + service.MustRegister[adapter.ConnectionManager](ctx, connectionManager) + router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions) + service.MustRegister[adapter.Router](ctx, router) + err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet) + if err != nil { + return nil, E.Cause(err, "initialize router") + } + ntpOptions := common.PtrValueOrDefault(options.NTP) + var timeService *tls.TimeServiceWrapper + if ntpOptions.Enabled { + timeService = new(tls.TimeServiceWrapper) + service.MustRegister[ntp.TimeService](ctx, timeService) + } + for i, transportOptions := range dnsOptions.Servers { + var tag string + if transportOptions.Tag != "" { + tag = transportOptions.Tag + } else { + tag = F.ToString(i) + } + err = dnsTransportManager.Create( + ctx, + logFactory.NewLogger(F.ToString("dns/", transportOptions.Type, "[", tag, "]")), + tag, + transportOptions.Type, + transportOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize DNS server[", i, "]") + } + } + err = dnsRouter.Initialize(dnsOptions.Rules) + if err != nil { + return nil, E.Cause(err, "initialize dns router") + } + for i, endpointOptions := range options.Endpoints { + var tag string + if endpointOptions.Tag != "" { + tag = endpointOptions.Tag + } else { + tag = F.ToString(i) + } + endpointCtx := ctx + if tag != "" { + // TODO: remove this + endpointCtx = adapter.WithContext(endpointCtx, &adapter.InboundContext{ + Outbound: tag, + }) + } + err = endpointManager.Create( + endpointCtx, + router, + logFactory.NewLogger(F.ToString("endpoint/", endpointOptions.Type, "[", tag, "]")), + tag, + endpointOptions.Type, + endpointOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize endpoint[", i, "]") + } + } + for i, inboundOptions := range options.Inbounds { + var tag string + if inboundOptions.Tag != "" { + tag = inboundOptions.Tag + } else { + tag = F.ToString(i) + } + err = inboundManager.Create( + ctx, + router, + logFactory.NewLogger(F.ToString("inbound/", inboundOptions.Type, "[", tag, "]")), + tag, + inboundOptions.Type, + inboundOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize inbound[", i, "]") + } + } + for i, serviceOptions := range options.Services { + var tag string + if serviceOptions.Tag != "" { + tag = serviceOptions.Tag + } else { + tag = F.ToString(i) + } + err = serviceManager.Create( + ctx, + logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")), + tag, + serviceOptions.Type, + serviceOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize service[", i, "]") + } + } + for i, outboundOptions := range options.Outbounds { + var tag string + if outboundOptions.Tag != "" { + tag = outboundOptions.Tag + } else { + tag = F.ToString(i) + } + outboundCtx := ctx + if tag != "" { + // TODO: remove this + outboundCtx = adapter.WithContext(outboundCtx, &adapter.InboundContext{ + Outbound: tag, + }) + } + err = outboundManager.Create( + outboundCtx, + router, + logFactory.NewLogger(F.ToString("outbound/", outboundOptions.Type, "[", tag, "]")), + tag, + outboundOptions.Type, + outboundOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize outbound[", i, "]") + } + } + for i, certificateProviderOptions := range options.CertificateProviders { + var tag string + if certificateProviderOptions.Tag != "" { + tag = certificateProviderOptions.Tag + } else { + tag = F.ToString(i) + } + err = certificateProviderManager.Create( + ctx, + logFactory.NewLogger(F.ToString("certificate-provider/", certificateProviderOptions.Type, "[", tag, "]")), + tag, + certificateProviderOptions.Type, + certificateProviderOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize certificate provider[", i, "]") + } + } + outboundManager.Initialize(func() (adapter.Outbound, error) { + return direct.NewOutbound( + ctx, + router, + logFactory.NewLogger("outbound/direct"), + "direct", + option.DirectOutboundOptions{}, + ) + }) + dnsTransportManager.Initialize(func() (adapter.DNSTransport, error) { + return dnsTransportRegistry.CreateDNSTransport( + ctx, + logFactory.NewLogger("dns/local"), + "local", + C.DNSTypeLocal, + &option.LocalDNSServerOptions{}, + ) + }) + if platformInterface != nil { + err = platformInterface.Initialize(networkManager) + if err != nil { + return nil, E.Cause(err, "initialize platform interface") + } + } + if needCacheFile { + cacheFile := cachefile.New(ctx, logFactory.NewLogger("cache-file"), common.PtrValueOrDefault(experimentalOptions.CacheFile)) + service.MustRegister[adapter.CacheFile](ctx, cacheFile) + internalServices = append(internalServices, cacheFile) + } + if needClashAPI { + clashAPIOptions := common.PtrValueOrDefault(experimentalOptions.ClashAPI) + clashAPIOptions.ModeList = experimental.CalculateClashModeList(options.Options) + clashServer, err := experimental.NewClashServer(ctx, logFactory.(log.ObservableFactory), clashAPIOptions) + if err != nil { + return nil, E.Cause(err, "create clash-server") + } + router.AppendTracker(clashServer) + service.MustRegister[adapter.ClashServer](ctx, clashServer) + internalServices = append(internalServices, clashServer) + } + if needV2RayAPI { + v2rayServer, err := experimental.NewV2RayServer(logFactory.NewLogger("v2ray-api"), common.PtrValueOrDefault(experimentalOptions.V2RayAPI)) + if err != nil { + return nil, E.Cause(err, "create v2ray-server") + } + if v2rayServer.StatsService() != nil { + router.AppendTracker(v2rayServer.StatsService()) + internalServices = append(internalServices, v2rayServer) + service.MustRegister[adapter.V2RayServer](ctx, v2rayServer) + } + } + if ntpOptions.Enabled { + ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain()) + if err != nil { + return nil, E.Cause(err, "create NTP service") + } + ntpService := ntp.NewService(ntp.Options{ + Context: ctx, + Dialer: ntpDialer, + Logger: logFactory.NewLogger("ntp"), + Server: ntpOptions.ServerOptions.Build(), + Interval: time.Duration(ntpOptions.Interval), + WriteToSystem: ntpOptions.WriteToSystem, + }) + timeService.TimeService = ntpService + internalServices = append(internalServices, adapter.NewLifecycleService(ntpService, "ntp service")) + } + return &Box{ + network: networkManager, + endpoint: endpointManager, + inbound: inboundManager, + outbound: outboundManager, + dnsTransport: dnsTransportManager, + service: serviceManager, + certificateProvider: certificateProviderManager, + dnsRouter: dnsRouter, + connection: connectionManager, + router: router, + createdAt: createdAt, + logFactory: logFactory, + logger: logFactory.Logger(), + internalService: internalServices, + done: make(chan struct{}), + }, nil +} + +func (s *Box) PreStart() error { + err := s.preStart() + if err != nil { + // TODO: remove catch error + defer func() { + v := recover() + if v != nil { + println(err.Error()) + debug.PrintStack() + panic("panic on early close: " + fmt.Sprint(v)) + } + }() + s.Close() + return err + } + s.logger.Info("sing-box pre-started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") + return nil +} + +func (s *Box) Start() error { + err := s.start() + if err != nil { + // TODO: remove catch error + defer func() { + v := recover() + if v != nil { + println(err.Error()) + debug.PrintStack() + println("panic on early start: " + fmt.Sprint(v)) + } + }() + s.Close() + return err + } + s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") + return nil +} + +func (s *Box) preStart() error { + monitor := taskmonitor.New(s.logger, C.StartTimeout) + monitor.Start("start logger") + err := s.logFactory.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "start logger") + } + err = adapter.StartNamed(s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service, s.certificateProvider) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.network, s.connection, s.router, s.dnsRouter) + if err != nil { + return err + } + return nil +} + +func (s *Box) start() error { + err := s.preStart() + if err != nil { + return err + } + err = adapter.StartNamed(s.logger, adapter.StartStateStart, s.internalService) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.endpoint) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.certificateProvider) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.service) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.endpoint, s.certificateProvider, s.inbound, s.service) + if err != nil { + return err + } + err = adapter.StartNamed(s.logger, adapter.StartStatePostStart, s.internalService) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.endpoint, s.certificateProvider, s.inbound, s.service) + if err != nil { + return err + } + err = adapter.StartNamed(s.logger, adapter.StartStateStarted, s.internalService) + if err != nil { + return err + } + return nil +} + +func (s *Box) Close() error { + select { + case <-s.done: + return os.ErrClosed + default: + close(s.done) + } + var err error + for _, closeItem := range []struct { + name string + service adapter.Lifecycle + }{ + {"service", s.service}, + {"inbound", s.inbound}, + {"certificate-provider", s.certificateProvider}, + {"endpoint", s.endpoint}, + {"outbound", s.outbound}, + {"router", s.router}, + {"connection", s.connection}, + {"dns-router", s.dnsRouter}, + {"dns-transport", s.dnsTransport}, + {"network", s.network}, + } { + s.logger.Trace("close ", closeItem.name) + startTime := time.Now() + err = E.Append(err, closeItem.service.Close(), func(err error) error { + return E.Cause(err, "close ", closeItem.name) + }) + s.logger.Trace("close ", closeItem.name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + for _, lifecycleService := range s.internalService { + s.logger.Trace("close ", lifecycleService.Name()) + startTime := time.Now() + err = E.Append(err, lifecycleService.Close(), func(err error) error { + return E.Cause(err, "close ", lifecycleService.Name()) + }) + s.logger.Trace("close ", lifecycleService.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + s.logger.Trace("close logger") + startTime := time.Now() + err = E.Append(err, s.logFactory.Close(), func(err error) error { + return E.Cause(err, "close logger") + }) + s.logger.Trace("close logger completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + return err +} + +func (s *Box) Network() adapter.NetworkManager { + return s.network +} + +func (s *Box) Router() adapter.Router { + return s.router +} + +func (s *Box) Inbound() adapter.InboundManager { + return s.inbound +} + +func (s *Box) Outbound() adapter.OutboundManager { + return s.outbound +} + +func (s *Box) Endpoint() adapter.EndpointManager { + return s.endpoint +} + +func (s *Box) LogFactory() log.Factory { + return s.logFactory +} diff --git a/cmd/internal/app_store_connect/main.go b/cmd/internal/app_store_connect/main.go new file mode 100644 index 00000000..d415abd6 --- /dev/null +++ b/cmd/internal/app_store_connect/main.go @@ -0,0 +1,452 @@ +package main + +import ( + "context" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/sagernet/asc-go/asc" + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +func main() { + ctx := context.Background() + switch os.Args[1] { + case "next_macos_project_version": + err := fetchMacOSVersion(ctx) + if err != nil { + log.Fatal(err) + } + case "publish_testflight": + err := publishTestflight(ctx) + if err != nil { + log.Fatal(err) + } + case "cancel_app_store": + err := cancelAppStore(ctx, os.Args[2]) + if err != nil { + log.Fatal(err) + } + case "prepare_app_store": + err := prepareAppStore(ctx) + if err != nil { + log.Fatal(err) + } + case "publish_app_store": + err := publishAppStore(ctx) + if err != nil { + log.Fatal(err) + } + default: + log.Fatal("unknown action: ", os.Args[1]) + } +} + +const ( + appID = "6673731168" + groupID = "5c5f3b78-b7a0-40c0-bcad-e6ef87bbefda" +) + +func createClient(expireDuration time.Duration) *asc.Client { + privateKey, err := os.ReadFile(os.Getenv("ASC_KEY_PATH")) + if err != nil { + log.Fatal(err) + } + tokenConfig, err := asc.NewTokenConfig(os.Getenv("ASC_KEY_ID"), os.Getenv("ASC_KEY_ISSUER_ID"), expireDuration, privateKey) + if err != nil { + log.Fatal(err) + } + return asc.NewClient(tokenConfig.Client()) +} + +func fetchMacOSVersion(ctx context.Context) error { + client := createClient(time.Minute) + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{"MAC_OS"}, + }) + if err != nil { + return err + } + var versionID string +findVersion: + for _, version := range versions.Data { + switch *version.Attributes.AppStoreState { + case asc.AppStoreVersionStateReadyForSale, + asc.AppStoreVersionStatePendingDeveloperRelease: + versionID = version.ID + break findVersion + } + } + if versionID == "" { + return E.New("no version found") + } + latestBuild, _, err := client.Builds.GetBuildForAppStoreVersion(ctx, versionID, &asc.GetBuildForAppStoreVersionQuery{}) + if err != nil { + return err + } + versionInt, err := strconv.Atoi(*latestBuild.Data.Attributes.Version) + if err != nil { + return E.Cause(err, "parse version code") + } + os.Stdout.WriteString(F.ToString(versionInt+1, "\n")) + return nil +} + +func publishTestflight(ctx context.Context) error { + if len(os.Args) < 3 { + return E.New("platform required: ios, macos, or tvos") + } + var platform asc.Platform + switch os.Args[2] { + case "ios": + platform = asc.PlatformIOS + case "macos": + platform = asc.PlatformMACOS + case "tvos": + platform = asc.PlatformTVOS + default: + return E.New("unknown platform: ", os.Args[2]) + } + + tagVersion, err := build_shared.ReadTagVersion() + if err != nil { + return err + } + tag := tagVersion.VersionString() + + releaseNotes := F.ToString("sing-box ", tagVersion.String()) + if len(os.Args) >= 4 { + releaseNotes = strings.Join(os.Args[3:], " ") + } + + client := createClient(20 * time.Minute) + + log.Info(tag, " list build IDs") + buildIDsResponse, _, err := client.TestFlight.ListBuildIDsForBetaGroup(ctx, groupID, nil) + if err != nil { + return err + } + buildIDs := common.Map(buildIDsResponse.Data, func(it asc.RelationshipData) string { + return it.ID + }) + + waitingForProcess := false + log.Info(string(platform), " list builds") + for { + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + }) + if err != nil { + return err + } + build := builds.Data[0] + log.Info(string(platform), " ", tag, " found build: ", build.ID, " (", *build.Attributes.Version, ")") + if !waitingForProcess && (common.Contains(buildIDs, build.ID) || time.Since(build.Attributes.UploadedDate.Time) > 30*time.Minute) { + log.Info(string(platform), " ", tag, " waiting for process") + time.Sleep(15 * time.Second) + continue + } + if *build.Attributes.ProcessingState != "VALID" { + waitingForProcess = true + log.Info(string(platform), " ", tag, " waiting for process: ", *build.Attributes.ProcessingState) + time.Sleep(15 * time.Second) + continue + } + log.Info(string(platform), " ", tag, " list localizations") + localizations, _, err := client.TestFlight.ListBetaBuildLocalizationsForBuild(ctx, build.ID, nil) + if err != nil { + return err + } + localization := common.Find(localizations.Data, func(it asc.BetaBuildLocalization) bool { + return *it.Attributes.Locale == "en-US" + }) + if localization.ID == "" { + log.Fatal(string(platform), " ", tag, " no en-US localization found") + } + if localization.Attributes == nil || localization.Attributes.WhatsNew == nil || *localization.Attributes.WhatsNew == "" { + log.Info(string(platform), " ", tag, " update localization") + _, _, err = client.TestFlight.UpdateBetaBuildLocalization(ctx, localization.ID, common.Ptr(releaseNotes)) + if err != nil { + return err + } + } + log.Info(string(platform), " ", tag, " publish") + response, err := client.TestFlight.AddBuildsToBetaGroup(ctx, groupID, []string{build.ID}) + if response != nil && (response.StatusCode == http.StatusUnprocessableEntity || response.StatusCode == http.StatusNotFound) { + log.Info("waiting for process") + time.Sleep(15 * time.Second) + continue + } else if err != nil { + return err + } + log.Info(string(platform), " ", tag, " list submissions") + betaSubmissions, _, err := client.TestFlight.ListBetaAppReviewSubmissions(ctx, &asc.ListBetaAppReviewSubmissionsQuery{ + FilterBuild: []string{build.ID}, + }) + if err != nil { + return err + } + if len(betaSubmissions.Data) == 0 { + log.Info(string(platform), " ", tag, " create submission") + _, _, err = client.TestFlight.CreateBetaAppReviewSubmission(ctx, build.ID) + if err != nil { + if strings.Contains(err.Error(), "ANOTHER_BUILD_IN_REVIEW") { + log.Error(err) + break + } + return err + } + } + break + } + return nil +} + +func cancelAppStore(ctx context.Context, platform string) error { + switch platform { + case "ios": + platform = string(asc.PlatformIOS) + case "macos": + platform = string(asc.PlatformMACOS) + case "tvos": + platform = string(asc.PlatformTVOS) + } + tag, err := build_shared.ReadTag() + if err != nil { + return err + } + client := createClient(time.Minute) + for { + log.Info(platform, " list versions") + versions, response, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + }) + if isRetryable(response) { + continue + } else if err != nil { + return err + } + version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { + return *it.Attributes.VersionString == tag + }) + if version.ID == "" { + return nil + } + log.Info(platform, " ", tag, " get submission") + submission, response, err := client.Submission.GetAppStoreVersionSubmissionForAppStoreVersion(ctx, version.ID, nil) + if response != nil && response.StatusCode == http.StatusNotFound { + return nil + } + if isRetryable(response) { + continue + } else if err != nil { + return err + } + log.Info(platform, " ", tag, " delete submission") + _, err = client.Submission.DeleteSubmission(ctx, submission.Data.ID) + if err != nil { + return err + } + return nil + } +} + +func prepareAppStore(ctx context.Context) error { + tag, err := build_shared.ReadTag() + if err != nil { + return err + } + client := createClient(time.Minute) + for _, platform := range []asc.Platform{ + asc.PlatformIOS, + asc.PlatformMACOS, + asc.PlatformTVOS, + } { + log.Info(string(platform), " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + }) + if err != nil { + return err + } + version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { + return *it.Attributes.VersionString == tag + }) + log.Info(string(platform), " ", tag, " list builds") + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + }) + if err != nil { + return err + } + if len(builds.Data) == 0 { + log.Fatal(platform, " ", tag, " no build found") + } + buildID := common.Ptr(builds.Data[0].ID) + if version.ID == "" { + log.Info(string(platform), " ", tag, " create version") + newVersion, _, err := client.Apps.CreateAppStoreVersion(ctx, asc.AppStoreVersionCreateRequestAttributes{ + Platform: platform, + VersionString: tag, + }, appID, buildID) + if err != nil { + return err + } + version = newVersion.Data + + } else { + log.Info(string(platform), " ", tag, " check build") + currentBuild, response, err := client.Apps.GetBuildIDForAppStoreVersion(ctx, version.ID) + if err != nil { + return err + } + if response.StatusCode != http.StatusOK || currentBuild.Data.ID != *buildID { + switch *version.Attributes.AppStoreState { + case asc.AppStoreVersionStatePrepareForSubmission, + asc.AppStoreVersionStateRejected, + asc.AppStoreVersionStateDeveloperRejected: + case asc.AppStoreVersionStateWaitingForReview, + asc.AppStoreVersionStateInReview, + asc.AppStoreVersionStatePendingDeveloperRelease: + submission, _, err := client.Submission.GetAppStoreVersionSubmissionForAppStoreVersion(ctx, version.ID, nil) + if err != nil { + return err + } + if submission != nil { + log.Info(string(platform), " ", tag, " delete submission") + _, err = client.Submission.DeleteSubmission(ctx, submission.Data.ID) + if err != nil { + return err + } + time.Sleep(5 * time.Second) + } + default: + log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) + } + log.Info(string(platform), " ", tag, " update build") + response, err = client.Apps.UpdateBuildForAppStoreVersion(ctx, version.ID, buildID) + if err != nil { + return err + } + if response.StatusCode != http.StatusNoContent { + response.Write(os.Stderr) + log.Fatal(string(platform), " ", tag, " unexpected response: ", response.Status) + } + } else { + switch *version.Attributes.AppStoreState { + case asc.AppStoreVersionStatePrepareForSubmission, + asc.AppStoreVersionStateRejected, + asc.AppStoreVersionStateDeveloperRejected: + case asc.AppStoreVersionStateWaitingForReview, + asc.AppStoreVersionStateInReview, + asc.AppStoreVersionStatePendingDeveloperRelease: + continue + default: + log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) + } + } + } + log.Info(string(platform), " ", tag, " list localization") + localizations, _, err := client.Apps.ListLocalizationsForAppStoreVersion(ctx, version.ID, nil) + if err != nil { + return err + } + localization := common.Find(localizations.Data, func(it asc.AppStoreVersionLocalization) bool { + return *it.Attributes.Locale == "en-US" + }) + if localization.ID == "" { + log.Info(string(platform), " ", tag, " no en-US localization found") + } + if localization.Attributes == nil || localization.Attributes.WhatsNew == nil || *localization.Attributes.WhatsNew == "" { + log.Info(string(platform), " ", tag, " update localization") + _, _, err = client.Apps.UpdateAppStoreVersionLocalization(ctx, localization.ID, &asc.AppStoreVersionLocalizationUpdateRequestAttributes{ + PromotionalText: common.Ptr("Yet another distribution for sing-box, the universal proxy platform."), + WhatsNew: common.Ptr(F.ToString("sing-box ", tag, ": Fixes and improvements.")), + }) + if err != nil { + return err + } + } + log.Info(string(platform), " ", tag, " create submission") + fixSubmit: + for { + _, response, err := client.Submission.CreateSubmission(ctx, version.ID) + if err != nil { + switch response.StatusCode { + case http.StatusInternalServerError: + continue + default: + return err + } + } + switch response.StatusCode { + case http.StatusCreated: + break fixSubmit + default: + return err + } + } + } + return nil +} + +func publishAppStore(ctx context.Context) error { + tag, err := build_shared.ReadTag() + if err != nil { + return err + } + client := createClient(time.Minute) + for _, platform := range []asc.Platform{ + asc.PlatformIOS, + asc.PlatformMACOS, + asc.PlatformTVOS, + } { + log.Info(string(platform), " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + }) + if err != nil { + return err + } + version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { + return *it.Attributes.VersionString == tag + }) + switch *version.Attributes.AppStoreState { + case asc.AppStoreVersionStatePrepareForSubmission, asc.AppStoreVersionStateDeveloperRejected: + log.Fatal(string(platform), " ", tag, " not submitted") + case asc.AppStoreVersionStateWaitingForReview, + asc.AppStoreVersionStateInReview: + log.Warn(string(platform), " ", tag, " waiting for review") + continue + case asc.AppStoreVersionStatePendingDeveloperRelease: + default: + log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) + } + _, _, err = client.Publishing.CreatePhasedRelease(ctx, common.Ptr(asc.PhasedReleaseStateComplete), version.ID) + if err != nil { + return err + } + } + return nil +} + +func isRetryable(response *asc.Response) bool { + if response == nil { + return false + } + switch response.StatusCode { + case http.StatusInternalServerError, http.StatusUnprocessableEntity: + return true + default: + return false + } +} diff --git a/cmd/internal/build/main.go b/cmd/internal/build/main.go new file mode 100644 index 00000000..cae67ba4 --- /dev/null +++ b/cmd/internal/build/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "go/build" + "os" + "os/exec" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" +) + +func main() { + build_shared.FindSDK() + + if os.Getenv("GOPATH") == "" { + os.Setenv("GOPATH", build.Default.GOPATH) + } + + command := exec.Command(os.Args[1], os.Args[2:]...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + err := command.Run() + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/internal/build_libbox/main.go b/cmd/internal/build_libbox/main.go new file mode 100644 index 00000000..c1282169 --- /dev/null +++ b/cmd/internal/build_libbox/main.go @@ -0,0 +1,245 @@ +package main + +import ( + "flag" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + _ "github.com/sagernet/gomobile" + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/rw" + "github.com/sagernet/sing/common/shell" +) + +var ( + debugEnabled bool + target string + platform string + // withTailscale bool +) + +func init() { + flag.BoolVar(&debugEnabled, "debug", false, "enable debug") + flag.StringVar(&target, "target", "android", "target platform") + flag.StringVar(&platform, "platform", "", "specify platform") + // flag.BoolVar(&withTailscale, "with-tailscale", false, "build tailscale for iOS and tvOS") +} + +func main() { + flag.Parse() + + build_shared.FindMobile() + + switch target { + case "android": + buildAndroid() + case "apple": + buildApple() + } +} + +var ( + sharedFlags []string + debugFlags []string + sharedTags []string + darwinTags []string + // memcTags []string + notMemcTags []string + debugTags []string +) + +func init() { + sharedFlags = append(sharedFlags, "-trimpath") + sharedFlags = append(sharedFlags, "-buildvcs=false") + currentTag, err := build_shared.ReadTag() + if err != nil { + currentTag = "unknown" + } + sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0") + debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0") + + sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0") + darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace") + // memcTags = append(memcTags, "with_tailscale") + sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird") + notMemcTags = append(notMemcTags, "with_low_memory") + debugTags = append(debugTags, "debug") +} + +type AndroidBuildConfig struct { + AndroidAPI int + OutputName string + Tags []string +} + +func filterTags(tags []string, exclude ...string) []string { + excludeMap := make(map[string]bool) + for _, tag := range exclude { + excludeMap[tag] = true + } + var result []string + for _, tag := range tags { + if !excludeMap[tag] { + result = append(result, tag) + } + } + return result +} + +func checkJavaVersion() { + var javaPath string + javaHome := os.Getenv("JAVA_HOME") + if javaHome == "" { + javaPath = "java" + } else { + javaPath = filepath.Join(javaHome, "bin", "java") + } + + javaVersion, err := shell.Exec(javaPath, "--version").ReadOutput() + if err != nil { + log.Fatal(E.Cause(err, "check java version")) + } + if !strings.Contains(javaVersion, "openjdk 17") { + log.Fatal("java version should be openjdk 17") + } +} + +func getAndroidBindTarget() string { + if platform != "" { + return platform + } else if debugEnabled { + return "android/arm64" + } + return "android" +} + +func buildAndroidVariant(config AndroidBuildConfig, bindTarget string) { + args := []string{ + "bind", + "-v", + "-o", config.OutputName, + "-target", bindTarget, + "-androidapi", strconv.Itoa(config.AndroidAPI), + "-javapkg=io.nekohasekai", + "-libname=box", + } + + if !debugEnabled { + args = append(args, sharedFlags...) + } else { + args = append(args, debugFlags...) + } + + args = append(args, "-tags", strings.Join(config.Tags, ",")) + args = append(args, "./experimental/libbox") + + command := exec.Command(build_shared.GoBinPath+"/gomobile", args...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + err := command.Run() + if err != nil { + log.Fatal(err) + } + + copyPath := filepath.Join("..", "sing-box-for-android", "app", "libs") + if rw.IsDir(copyPath) { + copyPath, _ = filepath.Abs(copyPath) + err = rw.CopyFile(config.OutputName, filepath.Join(copyPath, config.OutputName)) + if err != nil { + log.Fatal(err) + } + log.Info("copied ", config.OutputName, " to ", copyPath) + } +} + +func buildAndroid() { + build_shared.FindSDK() + checkJavaVersion() + + bindTarget := getAndroidBindTarget() + + // Build main variant (SDK 23) + mainTags := append([]string{}, sharedTags...) + // mainTags = append(mainTags, memcTags...) + if debugEnabled { + mainTags = append(mainTags, debugTags...) + } + buildAndroidVariant(AndroidBuildConfig{ + AndroidAPI: 23, + OutputName: "libbox.aar", + Tags: mainTags, + }, bindTarget) + + // Build legacy variant (SDK 21, no naive outbound) + legacyTags := filterTags(sharedTags, "with_naive_outbound") + // legacyTags = append(legacyTags, memcTags...) + if debugEnabled { + legacyTags = append(legacyTags, debugTags...) + } + buildAndroidVariant(AndroidBuildConfig{ + AndroidAPI: 21, + OutputName: "libbox-legacy.aar", + Tags: legacyTags, + }, bindTarget) +} + +func buildApple() { + var bindTarget string + if platform != "" { + bindTarget = platform + } else if debugEnabled { + bindTarget = "ios" + } else { + bindTarget = "ios,iossimulator,tvos,tvossimulator,macos" + } + + args := []string{ + "bind", + "-v", + "-target", bindTarget, + "-libname=box", + "-tags-not-macos=with_low_memory", + } + //if !withTailscale { + // args = append(args, "-tags-macos="+strings.Join(memcTags, ",")) + //} + + if !debugEnabled { + args = append(args, sharedFlags...) + } else { + args = append(args, debugFlags...) + } + + tags := append(sharedTags, darwinTags...) + //if withTailscale { + // tags = append(tags, memcTags...) + //} + if debugEnabled { + tags = append(tags, debugTags...) + } + + args = append(args, "-tags", strings.Join(tags, ",")) + args = append(args, "./experimental/libbox") + + command := exec.Command(build_shared.GoBinPath+"/gomobile", args...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + err := command.Run() + if err != nil { + log.Fatal(err) + } + + copyPath := filepath.Join("..", "sing-box-for-apple") + if rw.IsDir(copyPath) { + targetDir := filepath.Join(copyPath, "Libbox.xcframework") + targetDir, _ = filepath.Abs(targetDir) + os.RemoveAll(targetDir) + os.Rename("Libbox.xcframework", targetDir) + log.Info("copied to ", targetDir) + } +} diff --git a/cmd/internal/build_shared/sdk.go b/cmd/internal/build_shared/sdk.go new file mode 100644 index 00000000..5061c321 --- /dev/null +++ b/cmd/internal/build_shared/sdk.go @@ -0,0 +1,106 @@ +package build_shared + +import ( + "go/build" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/rw" +) + +var ( + androidSDKPath string + androidNDKPath string +) + +func FindSDK() { + searchPath := []string{ + "$ANDROID_HOME", + "$HOME/Android/Sdk", + "$HOME/.local/lib/android/sdk", + "$HOME/Library/Android/sdk", + } + for _, path := range searchPath { + path = os.ExpandEnv(path) + if rw.IsFile(filepath.Join(path, "licenses", "android-sdk-license")) { + androidSDKPath = path + break + } + } + if androidSDKPath == "" { + log.Fatal("android SDK not found") + } + if !findNDK() { + log.Fatal("android NDK not found") + } + + os.Setenv("ANDROID_HOME", androidSDKPath) + os.Setenv("ANDROID_SDK_HOME", androidSDKPath) + os.Setenv("ANDROID_NDK_HOME", androidNDKPath) + os.Setenv("NDK", androidNDKPath) + os.Setenv("PATH", os.Getenv("PATH")+":"+filepath.Join(androidNDKPath, "toolchains", "llvm", "prebuilt", runtime.GOOS+"-x86_64", "bin")) +} + +func findNDK() bool { + const fixedVersion = "28.0.13004108" + const versionFile = "source.properties" + if fixedPath := filepath.Join(androidSDKPath, "ndk", fixedVersion); rw.IsFile(filepath.Join(fixedPath, versionFile)) { + androidNDKPath = fixedPath + return true + } + if ndkHomeEnv := os.Getenv("ANDROID_NDK_HOME"); rw.IsFile(filepath.Join(ndkHomeEnv, versionFile)) { + androidNDKPath = ndkHomeEnv + return true + } + ndkVersions, err := os.ReadDir(filepath.Join(androidSDKPath, "ndk")) + if err != nil { + return false + } + versionNames := common.Map(ndkVersions, os.DirEntry.Name) + if len(versionNames) == 0 { + return false + } + sort.Slice(versionNames, func(i, j int) bool { + iVersions := strings.Split(versionNames[i], ".") + jVersions := strings.Split(versionNames[j], ".") + for k := 0; k < len(iVersions) && k < len(jVersions); k++ { + iVersion, _ := strconv.Atoi(iVersions[k]) + jVersion, _ := strconv.Atoi(jVersions[k]) + if iVersion != jVersion { + return iVersion > jVersion + } + } + return true + }) + for _, versionName := range versionNames { + currentNDKPath := filepath.Join(androidSDKPath, "ndk", versionName) + if rw.IsFile(filepath.Join(currentNDKPath, versionFile)) { + androidNDKPath = currentNDKPath + log.Warn("reproducibility warning: using NDK version " + versionName + " instead of " + fixedVersion) + return true + } + } + return false +} + +var GoBinPath string + +func FindMobile() { + goBin := filepath.Join(build.Default.GOPATH, "bin") + if runtime.GOOS == "windows" { + if !rw.IsFile(filepath.Join(goBin, "gobind.exe")) { + log.Fatal("missing gomobile installation") + } + } else { + if !rw.IsFile(filepath.Join(goBin, "gobind")) { + log.Fatal("missing gomobile installation") + } + } + GoBinPath = goBin +} diff --git a/cmd/internal/build_shared/tag.go b/cmd/internal/build_shared/tag.go new file mode 100644 index 00000000..15f05090 --- /dev/null +++ b/cmd/internal/build_shared/tag.go @@ -0,0 +1,38 @@ +package build_shared + +import ( + "github.com/sagernet/sing-box/common/badversion" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/shell" +) + +func ReadTag() (string, error) { + currentTag, err := shell.Exec("git", "describe", "--tags").ReadOutput() + if err != nil { + return currentTag, err + } + currentTagRev, _ := shell.Exec("git", "describe", "--tags", "--abbrev=0").ReadOutput() + if currentTagRev == currentTag { + return currentTag[1:], nil + } + shortCommit, _ := shell.Exec("git", "rev-parse", "--short", "HEAD").ReadOutput() + version := badversion.Parse(currentTagRev[1:]) + return version.String() + "-" + shortCommit, nil +} + +func ReadTagVersionRev() (badversion.Version, error) { + currentTagRev := common.Must1(shell.Exec("git", "describe", "--tags", "--abbrev=0").ReadOutput()) + return badversion.Parse(currentTagRev[1:]), nil +} + +func ReadTagVersion() (badversion.Version, error) { + currentTag := common.Must1(shell.Exec("git", "describe", "--tags").ReadOutput()) + currentTagRev := common.Must1(shell.Exec("git", "describe", "--tags", "--abbrev=0").ReadOutput()) + version := badversion.Parse(currentTagRev[1:]) + if currentTagRev != currentTag { + if version.PreReleaseIdentifier == "" { + version.Patch++ + } + } + return version, nil +} diff --git a/cmd/internal/format_docs/main.go b/cmd/internal/format_docs/main.go new file mode 100644 index 00000000..061b2121 --- /dev/null +++ b/cmd/internal/format_docs/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/log" +) + +func main() { + err := filepath.Walk("docs", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".md") { + return nil + } + return processFile(path) + }) + if err != nil { + log.Fatal(err) + } +} + +func processFile(path string) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + modified := false + result := make([]string, 0, len(lines)) + + inQuoteBlock := false + materialLines := []int{} // indices of :material- lines in the block + + for _, line := range lines { + // Check for quote block start + if strings.HasPrefix(line, "!!! quote \"") && strings.Contains(line, "sing-box") { + inQuoteBlock = true + materialLines = nil + result = append(result, line) + continue + } + + // Inside a quote block + if inQuoteBlock { + trimmed := strings.TrimPrefix(line, " ") + isMaterialLine := strings.HasPrefix(trimmed, ":material-") + isEmpty := strings.TrimSpace(line) == "" + isIndented := strings.HasPrefix(line, " ") + + if isMaterialLine { + materialLines = append(materialLines, len(result)) + result = append(result, line) + continue + } + + // Block ends when: + // - Empty line AFTER we've seen material lines, OR + // - Non-indented, non-empty line + blockEnds := (isEmpty && len(materialLines) > 0) || (!isEmpty && !isIndented) + if blockEnds { + // Process collected material lines + if len(materialLines) > 0 { + for j, idx := range materialLines { + isLast := j == len(materialLines)-1 + resultLine := strings.TrimRight(result[idx], " ") + if !isLast { + // Add trailing two spaces for non-last lines + resultLine += " " + } + if result[idx] != resultLine { + modified = true + result[idx] = resultLine + } + } + } + inQuoteBlock = false + materialLines = nil + } + } + + result = append(result, line) + } + + // Handle case where file ends while still in a block + if inQuoteBlock && len(materialLines) > 0 { + for j, idx := range materialLines { + isLast := j == len(materialLines)-1 + resultLine := strings.TrimRight(result[idx], " ") + if !isLast { + resultLine += " " + } + if result[idx] != resultLine { + modified = true + result[idx] = resultLine + } + } + } + + if modified { + newContent := strings.Join(result, "\n") + if !bytes.Equal(content, []byte(newContent)) { + log.Info("formatted: ", path) + return os.WriteFile(path, []byte(newContent), 0o644) + } + } + + return nil +} diff --git a/cmd/internal/protogen/main.go b/cmd/internal/protogen/main.go new file mode 100644 index 00000000..4d5023f7 --- /dev/null +++ b/cmd/internal/protogen/main.go @@ -0,0 +1,218 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "go/build" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// envFile returns the name of the Go environment configuration file. +// Copy from https://github.com/golang/go/blob/c4f2a9788a7be04daf931ac54382fbe2cb754938/src/cmd/go/internal/cfg/cfg.go#L150-L166 +func envFile() (string, error) { + if file := os.Getenv("GOENV"); file != "" { + if file == "off" { + return "", fmt.Errorf("GOENV=off") + } + return file, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + if dir == "" { + return "", fmt.Errorf("missing user-config dir") + } + return filepath.Join(dir, "go", "env"), nil +} + +// GetRuntimeEnv returns the value of runtime environment variable, +// that is set by running following command: `go env -w key=value`. +func GetRuntimeEnv(key string) (string, error) { + file, err := envFile() + if err != nil { + return "", err + } + if file == "" { + return "", fmt.Errorf("missing runtime env file") + } + var data []byte + var runtimeEnv string + data, readErr := os.ReadFile(file) + if readErr != nil { + return "", readErr + } + envStrings := strings.Split(string(data), "\n") + for _, envItem := range envStrings { + envItem = strings.TrimSuffix(envItem, "\r") + envKeyValue := strings.Split(envItem, "=") + if strings.EqualFold(strings.TrimSpace(envKeyValue[0]), key) { + runtimeEnv = strings.TrimSpace(envKeyValue[1]) + } + } + return runtimeEnv, nil +} + +// GetGOBIN returns GOBIN environment variable as a string. It will NOT be empty. +func GetGOBIN() string { + // The one set by user explicitly by `export GOBIN=/path` or `env GOBIN=/path command` + GOBIN := os.Getenv("GOBIN") + if GOBIN == "" { + var err error + // The one set by user by running `go env -w GOBIN=/path` + GOBIN, err = GetRuntimeEnv("GOBIN") + if err != nil { + // The default one that Golang uses + return filepath.Join(build.Default.GOPATH, "bin") + } + if GOBIN == "" { + return filepath.Join(build.Default.GOPATH, "bin") + } + return GOBIN + } + return GOBIN +} + +func main() { + pwd, err := os.Getwd() + if err != nil { + fmt.Println("Can not get current working directory.") + os.Exit(1) + } + + GOBIN := GetGOBIN() + binPath := os.Getenv("PATH") + pathSlice := []string{pwd, GOBIN, binPath} + binPath = strings.Join(pathSlice, string(os.PathListSeparator)) + os.Setenv("PATH", binPath) + + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + + protoc := "protoc" + + if linkPath, err := os.Readlink(protoc); err == nil { + protoc = linkPath + } + + protoFilesMap := make(map[string][]string) + walkErr := filepath.Walk("./", func(path string, info os.FileInfo, err error) error { + if err != nil { + fmt.Println(err) + return err + } + + if info.IsDir() { + return nil + } + + dir := filepath.Dir(path) + filename := filepath.Base(path) + if strings.HasSuffix(filename, ".proto") && + filename != "typed_message.proto" && + filename != "descriptor.proto" { + protoFilesMap[dir] = append(protoFilesMap[dir], path) + } + + return nil + }) + if walkErr != nil { + fmt.Println(walkErr) + os.Exit(1) + } + + for _, files := range protoFilesMap { + for _, relProtoFile := range files { + args := []string{ + "-I", ".", + "--go_out", pwd, + "--go_opt", "paths=source_relative", + "--go-grpc_out", pwd, + "--go-grpc_opt", "paths=source_relative", + "--plugin", "protoc-gen-go=" + filepath.Join(GOBIN, "protoc-gen-go"+suffix), + "--plugin", "protoc-gen-go-grpc=" + filepath.Join(GOBIN, "protoc-gen-go-grpc"+suffix), + } + args = append(args, relProtoFile) + cmd := exec.Command(protoc, args...) + cmd.Env = append(cmd.Env, os.Environ()...) + output, cmdErr := cmd.CombinedOutput() + if len(output) > 0 { + fmt.Println(string(output)) + } + if cmdErr != nil { + fmt.Println(cmdErr) + os.Exit(1) + } + } + } + + normalizeWalkErr := filepath.Walk("./", func(path string, info os.FileInfo, err error) error { + if err != nil { + fmt.Println(err) + return err + } + + if info.IsDir() { + return nil + } + + filename := filepath.Base(path) + if strings.HasSuffix(filename, ".pb.go") && + path != "config.pb.go" { + if err := NormalizeGeneratedProtoFile(path); err != nil { + fmt.Println(err) + os.Exit(1) + } + } + + return nil + }) + if normalizeWalkErr != nil { + fmt.Println(normalizeWalkErr) + os.Exit(1) + } +} + +func NormalizeGeneratedProtoFile(path string) error { + fd, err := os.OpenFile(path, os.O_RDWR, 0o644) + if err != nil { + return err + } + + _, err = fd.Seek(0, io.SeekStart) + if err != nil { + return err + } + out := bytes.NewBuffer(nil) + scanner := bufio.NewScanner(fd) + valid := false + for scanner.Scan() { + if !valid && !strings.HasPrefix(scanner.Text(), "package ") { + continue + } + valid = true + out.Write(scanner.Bytes()) + out.Write([]byte("\n")) + } + _, err = fd.Seek(0, io.SeekStart) + if err != nil { + return err + } + err = fd.Truncate(0) + if err != nil { + return err + } + _, err = io.Copy(fd, bytes.NewReader(out.Bytes())) + if err != nil { + return err + } + return nil +} diff --git a/cmd/internal/read_tag/main.go b/cmd/internal/read_tag/main.go new file mode 100644 index 00000000..c4da1de5 --- /dev/null +++ b/cmd/internal/read_tag/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "flag" + "os" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/common/badversion" + "github.com/sagernet/sing-box/log" +) + +var ( + flagRunInCI bool + flagRunNightly bool +) + +func init() { + flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI") + flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly") +} + +func main() { + flag.Parse() + var ( + versionStr string + err error + ) + if flagRunNightly { + var version badversion.Version + version, err = build_shared.ReadTagVersion() + if err == nil { + versionStr = version.String() + } + } else { + versionStr, err = build_shared.ReadTag() + } + if flagRunInCI { + if err != nil { + log.Fatal(err) + } + err = setGitHubEnv("version", versionStr) + if err != nil { + log.Fatal(err) + } + } else { + if err != nil { + log.Error(err) + os.Stdout.WriteString("unknown\n") + } else { + os.Stdout.WriteString(versionStr + "\n") + } + } +} + +func setGitHubEnv(name string, value string) error { + outputFile, err := os.OpenFile(os.Getenv("GITHUB_ENV"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + _, err = outputFile.WriteString(name + "=" + value + "\n") + if err != nil { + outputFile.Close() + return err + } + err = outputFile.Close() + if err != nil { + return err + } + os.Stderr.WriteString(name + "=" + value + "\n") + return nil +} diff --git a/cmd/internal/tun_bench/main.go b/cmd/internal/tun_bench/main.go new file mode 100644 index 00000000..e62841dc --- /dev/null +++ b/cmd/internal/tun_bench/main.go @@ -0,0 +1,284 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/netip" + "os" + "os/exec" + "strings" + "syscall" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/shell" +) + +var iperf3Path string + +func main() { + err := main0() + if err != nil { + log.Fatal(err) + } +} + +func main0() error { + err := shell.Exec("sudo", "ls").Run() + if err != nil { + return err + } + results, err := runTests() + if err != nil { + return err + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(results) +} + +func runTests() ([]TestResult, error) { + boxPaths := []string{ + os.ExpandEnv("$HOME/Downloads/sing-box-1.11.15-darwin-arm64/sing-box"), + //"/Users/sekai/Downloads/sing-box-1.11.15-linux-arm64/sing-box", + "./sing-box", + } + stacks := []string{ + "gvisor", + "system", + } + mtus := []int{ + 1500, + 4064, + // 16384, + // 32768, + // 49152, + 65535, + } + flagList := [][]string{ + {}, + } + var results []TestResult + for _, boxPath := range boxPaths { + for _, stack := range stacks { + for _, mtu := range mtus { + if strings.HasPrefix(boxPath, ".") { + for _, flags := range flagList { + result, err := testOnce(boxPath, stack, mtu, false, flags) + if err != nil { + return nil, err + } + results = append(results, *result) + } + } else { + result, err := testOnce(boxPath, stack, mtu, false, nil) + if err != nil { + return nil, err + } + results = append(results, *result) + } + } + } + } + return results, nil +} + +type TestResult struct { + BoxPath string `json:"box_path"` + Stack string `json:"stack"` + MTU int `json:"mtu"` + Flags []string `json:"flags"` + MultiThread bool `json:"multi_thread"` + UploadSpeed string `json:"upload_speed"` + DownloadSpeed string `json:"download_speed"` +} + +func testOnce(boxPath string, stackName string, mtu int, multiThread bool, flags []string) (result *TestResult, err error) { + testAddress := netip.MustParseAddr("1.1.1.1") + testConfig := option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeTun, + Options: &option.TunInboundOptions{ + Address: []netip.Prefix{netip.MustParsePrefix("172.18.0.1/30")}, + AutoRoute: true, + MTU: uint32(mtu), + Stack: stackName, + RouteAddress: []netip.Prefix{netip.PrefixFrom(testAddress, testAddress.BitLen())}, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + IPCIDR: []string{testAddress.String()}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRouteOptions, + RouteOptionsOptions: option.RouteOptionsActionOptions{ + OverrideAddress: "127.0.0.1", + }, + }, + }, + }, + }, + AutoDetectInterface: true, + }, + } + ctx := include.Context(context.Background()) + tempConfig, err := os.CreateTemp("", "tun-bench-*.json") + if err != nil { + return + } + defer os.Remove(tempConfig.Name()) + encoder := json.NewEncoderContext(ctx, tempConfig) + encoder.SetIndent("", " ") + err = encoder.Encode(testConfig) + if err != nil { + return nil, E.Cause(err, "encode test config") + } + tempConfig.Close() + var sudoArgs []string + if len(flags) > 0 { + sudoArgs = append(sudoArgs, "env") + sudoArgs = append(sudoArgs, flags...) + } + sudoArgs = append(sudoArgs, boxPath, "run", "-c", tempConfig.Name()) + boxProcess := shell.Exec("sudo", sudoArgs...) + boxProcess.Stdout = &stderrWriter{} + boxProcess.Stderr = io.Discard + err = boxProcess.Start() + if err != nil { + return + } + + if C.IsDarwin { + iperf3Path, err = exec.LookPath("iperf3-darwin") + } else { + iperf3Path, err = exec.LookPath("iperf3") + } + if err != nil { + return + } + serverProcess := shell.Exec(iperf3Path, "-s") + serverProcess.Stdout = io.Discard + serverProcess.Stderr = io.Discard + err = serverProcess.Start() + if err != nil { + return nil, E.Cause(err, "start iperf3 server") + } + + time.Sleep(time.Second) + + args := []string{"-c", testAddress.String()} + if multiThread { + args = append(args, "-P", "10") + } + + uploadProcess := shell.Exec(iperf3Path, args...) + output, err := uploadProcess.Read() + if err != nil { + boxProcess.Process.Signal(syscall.SIGKILL) + serverProcess.Process.Signal(syscall.SIGKILL) + println(output) + return + } + + uploadResult := common.SubstringBeforeLast(output, "iperf Done.") + uploadResult = common.SubstringBeforeLast(uploadResult, "sender") + uploadResult = common.SubstringBeforeLast(uploadResult, "bits/sec") + uploadResult = common.SubstringAfterLast(uploadResult, "Bytes") + uploadResult = strings.ReplaceAll(uploadResult, " ", "") + + result = &TestResult{ + BoxPath: boxPath, + Stack: stackName, + MTU: mtu, + Flags: flags, + MultiThread: multiThread, + UploadSpeed: uploadResult, + } + + downloadProcess := shell.Exec(iperf3Path, append(args, "-R")...) + output, err = downloadProcess.Read() + if err != nil { + boxProcess.Process.Signal(syscall.SIGKILL) + serverProcess.Process.Signal(syscall.SIGKILL) + println(output) + return + } + + downloadResult := common.SubstringBeforeLast(output, "iperf Done.") + downloadResult = common.SubstringBeforeLast(downloadResult, "receiver") + downloadResult = common.SubstringBeforeLast(downloadResult, "bits/sec") + downloadResult = common.SubstringAfterLast(downloadResult, "Bytes") + downloadResult = strings.ReplaceAll(downloadResult, " ", "") + + result.DownloadSpeed = downloadResult + + printArgs := []any{boxPath, stackName, mtu, "upload", uploadResult, "download", downloadResult} + if len(flags) > 0 { + printArgs = append(printArgs, "flags", strings.Join(flags, " ")) + } + if multiThread { + printArgs = append(printArgs, "(-P 10)") + } + fmt.Println(printArgs...) + err = boxProcess.Process.Signal(syscall.SIGTERM) + if err != nil { + return + } + + err = serverProcess.Process.Signal(syscall.SIGTERM) + if err != nil { + return + } + + boxDone := make(chan struct{}) + go func() { + boxProcess.Cmd.Wait() + close(boxDone) + }() + + serverDone := make(chan struct{}) + go func() { + serverProcess.Process.Wait() + close(serverDone) + }() + + select { + case <-boxDone: + case <-time.After(2 * time.Second): + boxProcess.Process.Kill() + case <-time.After(4 * time.Second): + println("box process did not close!") + os.Exit(1) + } + + select { + case <-serverDone: + case <-time.After(2 * time.Second): + serverProcess.Process.Kill() + case <-time.After(4 * time.Second): + println("server process did not close!") + os.Exit(1) + } + + return +} + +type stderrWriter struct{} + +func (w *stderrWriter) Write(p []byte) (n int, err error) { + return os.Stderr.Write(p) +} diff --git a/cmd/internal/update_android_version/main.go b/cmd/internal/update_android_version/main.go new file mode 100644 index 00000000..4850fce0 --- /dev/null +++ b/cmd/internal/update_android_version/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "flag" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" +) + +var ( + flagRunInCI bool + flagRunNightly bool +) + +func init() { + flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI") + flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly") +} + +func main() { + flag.Parse() + newVersion := common.Must1(build_shared.ReadTag()) + var androidPath string + if flagRunInCI { + androidPath = "clients/android" + } else { + androidPath = "../sing-box-for-android" + } + androidPath, err := filepath.Abs(androidPath) + if err != nil { + log.Fatal(err) + } + common.Must(os.Chdir(androidPath)) + localProps := common.Must1(os.ReadFile("version.properties")) + var propsList [][]string + for _, propLine := range strings.Split(string(localProps), "\n") { + propsList = append(propsList, strings.Split(propLine, "=")) + } + var ( + versionUpdated bool + goVersionUpdated bool + ) + for _, propPair := range propsList { + switch propPair[0] { + case "VERSION_NAME": + if propPair[1] != newVersion { + log.Info("updated version from ", propPair[1], " to ", newVersion) + versionUpdated = true + propPair[1] = newVersion + } + case "GO_VERSION": + if propPair[1] != runtime.Version() { + log.Info("updated Go version from ", propPair[1], " to ", runtime.Version()) + goVersionUpdated = true + propPair[1] = runtime.Version() + } + } + } + if !(versionUpdated || goVersionUpdated) { + log.Info("version not changed") + return + } else if flagRunInCI && !flagRunNightly { + log.Fatal("version changed, commit changes first.") + } + for _, propPair := range propsList { + switch propPair[0] { + case "VERSION_CODE": + versionCode := common.Must1(strconv.ParseInt(propPair[1], 10, 64)) + propPair[1] = strconv.Itoa(int(versionCode + 1)) + log.Info("updated version code to ", propPair[1]) + } + } + var newProps []string + for _, propPair := range propsList { + newProps = append(newProps, strings.Join(propPair, "=")) + } + common.Must(os.WriteFile("version.properties", []byte(strings.Join(newProps, "\n")), 0o644)) +} diff --git a/cmd/internal/update_apple_version/main.go b/cmd/internal/update_apple_version/main.go new file mode 100644 index 00000000..1b2d0db5 --- /dev/null +++ b/cmd/internal/update_apple_version/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "flag" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + + "howett.net/plist" +) + +var flagRunInCI bool + +func init() { + flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI") +} + +func main() { + flag.Parse() + newVersion := common.Must1(build_shared.ReadTagVersion()) + var applePath string + if flagRunInCI { + applePath = "clients/apple" + } else { + applePath = "../sing-box-for-apple" + } + applePath, err := filepath.Abs(applePath) + if err != nil { + log.Fatal(err) + } + common.Must(os.Chdir(applePath)) + projectFile := common.Must1(os.Open("sing-box.xcodeproj/project.pbxproj")) + var project map[string]any + decoder := plist.NewDecoder(projectFile) + common.Must(decoder.Decode(&project)) + objectsMap := project["objects"].(map[string]any) + projectContent := string(common.Must1(os.ReadFile("sing-box.xcodeproj/project.pbxproj"))) + newContent, updated0 := findAndReplace(objectsMap, projectContent, []string{"io.nekohasekai.sfavt"}, newVersion.VersionString()) + newContent, updated1 := findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfavt.standalone", "io.nekohasekai.sfavt.system"}, newVersion.String()) + if updated0 || updated1 { + log.Info("updated version to ", newVersion.VersionString(), " (", newVersion.String(), ")") + } + var updated2 bool + if macProjectVersion := os.Getenv("MACOS_PROJECT_VERSION"); macProjectVersion != "" { + newContent, updated2 = findAndReplaceProjectVersion(objectsMap, newContent, []string{"SFM"}, macProjectVersion) + if updated2 { + log.Info("updated macos project version to ", macProjectVersion) + } + } + if updated0 || updated1 || updated2 { + common.Must(os.WriteFile("sing-box.xcodeproj/project.pbxproj", []byte(newContent), 0o644)) + } +} + +func findAndReplace(objectsMap map[string]any, projectContent string, bundleIDList []string, newVersion string) (string, bool) { + objectKeyList := findObjectKey(objectsMap, bundleIDList) + var updated bool + for _, objectKey := range objectKeyList { + matchRegexp := common.Must1(regexp.Compile(objectKey + ".*= \\{")) + indexes := matchRegexp.FindStringIndex(projectContent) + if len(indexes) < 2 { + println(projectContent) + log.Fatal("failed to find object key ", objectKey, ": ", strings.Index(projectContent, objectKey)) + } + indexStart := indexes[1] + indexEnd := indexStart + strings.Index(projectContent[indexStart:], "}") + versionStart := indexStart + strings.Index(projectContent[indexStart:indexEnd], "MARKETING_VERSION = ") + 20 + versionEnd := versionStart + strings.Index(projectContent[versionStart:indexEnd], ";") + version := strings.Trim(projectContent[versionStart:versionEnd], "\"") + if version == newVersion { + continue + } + updated = true + projectContent = projectContent[:versionStart] + "\"" + newVersion + "\"" + projectContent[versionEnd:] + } + return projectContent, updated +} + +func findAndReplaceProjectVersion(objectsMap map[string]any, projectContent string, directoryList []string, newVersion string) (string, bool) { + objectKeyList := findObjectKeyByDirectory(objectsMap, directoryList) + var updated bool + for _, objectKey := range objectKeyList { + matchRegexp := common.Must1(regexp.Compile(objectKey + ".*= \\{")) + indexes := matchRegexp.FindStringIndex(projectContent) + if len(indexes) < 2 { + println(projectContent) + log.Fatal("failed to find object key ", objectKey, ": ", strings.Index(projectContent, objectKey)) + } + indexStart := indexes[1] + indexEnd := indexStart + strings.Index(projectContent[indexStart:], "}") + versionStart := indexStart + strings.Index(projectContent[indexStart:indexEnd], "CURRENT_PROJECT_VERSION = ") + 26 + versionEnd := versionStart + strings.Index(projectContent[versionStart:indexEnd], ";") + version := projectContent[versionStart:versionEnd] + if version == newVersion { + continue + } + updated = true + projectContent = projectContent[:versionStart] + newVersion + projectContent[versionEnd:] + } + return projectContent, updated +} + +func findObjectKey(objectsMap map[string]any, bundleIDList []string) []string { + var objectKeyList []string + for objectKey, object := range objectsMap { + buildSettings := object.(map[string]any)["buildSettings"] + if buildSettings == nil { + continue + } + bundleIDObject := buildSettings.(map[string]any)["PRODUCT_BUNDLE_IDENTIFIER"] + if bundleIDObject == nil { + continue + } + if common.Contains(bundleIDList, bundleIDObject.(string)) { + objectKeyList = append(objectKeyList, objectKey) + } + } + return objectKeyList +} + +func findObjectKeyByDirectory(objectsMap map[string]any, directoryList []string) []string { + var objectKeyList []string + for objectKey, object := range objectsMap { + buildSettings := object.(map[string]any)["buildSettings"] + if buildSettings == nil { + continue + } + infoPListFile := buildSettings.(map[string]any)["INFOPLIST_FILE"] + if infoPListFile == nil { + continue + } + for _, searchDirectory := range directoryList { + if strings.HasPrefix(infoPListFile.(string), searchDirectory+"/") { + objectKeyList = append(objectKeyList, objectKey) + } + } + + } + return objectKeyList +} diff --git a/cmd/internal/update_certificates/main.go b/cmd/internal/update_certificates/main.go new file mode 100644 index 00000000..55b221e1 --- /dev/null +++ b/cmd/internal/update_certificates/main.go @@ -0,0 +1,166 @@ +package main + +import ( + "encoding/csv" + "io" + "net/http" + "os" + "strings" + + "github.com/sagernet/sing-box/log" + + "golang.org/x/exp/slices" +) + +func main() { + err := updateMozillaIncludedRootCAs() + if err != nil { + log.Error(err) + } + err = updateChromeIncludedRootCAs() + if err != nil { + log.Error(err) + } +} + +func updateMozillaIncludedRootCAs() error { + response, err := http.Get("https://ccadb.my.salesforce-sites.com/mozilla/IncludedCACertificateReportPEMCSV") + if err != nil { + return err + } + defer response.Body.Close() + reader := csv.NewReader(response.Body) + header, err := reader.Read() + if err != nil { + return err + } + geoIndex := slices.Index(header, "Geographic Focus") + nameIndex := slices.Index(header, "Common Name or Certificate Name") + certIndex := slices.Index(header, "PEM Info") + + generated := strings.Builder{} + generated.WriteString(`// Code generated by 'make update_certificates'. DO NOT EDIT. + +package certificate + +import "crypto/x509" + +var mozillaIncluded *x509.CertPool + +func init() { + mozillaIncluded = x509.NewCertPool() +`) + for { + record, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + return err + } + if record[geoIndex] == "China" { + continue + } + generated.WriteString("\n // ") + generated.WriteString(record[nameIndex]) + generated.WriteString("\n") + generated.WriteString(" mozillaIncluded.AppendCertsFromPEM([]byte(`") + cert := record[certIndex] + // Remove single quotes + cert = cert[1 : len(cert)-1] + generated.WriteString(cert) + generated.WriteString("`))\n") + } + generated.WriteString("}\n") + return os.WriteFile("common/certificate/mozilla.go", []byte(generated.String()), 0o644) +} + +func fetchChinaFingerprints() (map[string]bool, error) { + response, err := http.Get("https://ccadb.my.salesforce-sites.com/ccadb/AllCertificateRecordsCSVFormatv4") + if err != nil { + return nil, err + } + defer response.Body.Close() + reader := csv.NewReader(response.Body) + header, err := reader.Read() + if err != nil { + return nil, err + } + countryIndex := slices.Index(header, "Country") + fingerprintIndex := slices.Index(header, "SHA-256 Fingerprint") + + chinaFingerprints := make(map[string]bool) + for { + record, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + if record[countryIndex] == "China" { + chinaFingerprints[record[fingerprintIndex]] = true + } + } + return chinaFingerprints, nil +} + +func updateChromeIncludedRootCAs() error { + chinaFingerprints, err := fetchChinaFingerprints() + if err != nil { + return err + } + + response, err := http.Get("https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesIncludedByRSReportCSV") + if err != nil { + return err + } + defer response.Body.Close() + reader := csv.NewReader(response.Body) + header, err := reader.Read() + if err != nil { + return err + } + subjectIndex := slices.Index(header, "Subject") + statusIndex := slices.Index(header, "Google Chrome Status") + certIndex := slices.Index(header, "X.509 Certificate (PEM)") + fingerprintIndex := slices.Index(header, "SHA-256 Fingerprint") + + generated := strings.Builder{} + generated.WriteString(`// Code generated by 'make update_certificates'. DO NOT EDIT. + +package certificate + +import "crypto/x509" + +var chromeIncluded *x509.CertPool + +func init() { + chromeIncluded = x509.NewCertPool() +`) + for { + record, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + return err + } + if record[statusIndex] != "Included" { + continue + } + if chinaFingerprints[record[fingerprintIndex]] { + continue + } + generated.WriteString("\n // ") + generated.WriteString(record[subjectIndex]) + generated.WriteString("\n") + generated.WriteString(" chromeIncluded.AppendCertsFromPEM([]byte(`") + cert := record[certIndex] + // Remove single quotes if present + if len(cert) > 0 && cert[0] == '\'' { + cert = cert[1 : len(cert)-1] + } + generated.WriteString(cert) + generated.WriteString("`))\n") + } + generated.WriteString("}\n") + return os.WriteFile("common/certificate/chrome.go", []byte(generated.String()), 0o644) +} diff --git a/cmd/sing-box/cmd.go b/cmd/sing-box/cmd.go new file mode 100644 index 00000000..575cb7a0 --- /dev/null +++ b/cmd/sing-box/cmd.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "os" + "os/user" + "strconv" + "time" + + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" + + "github.com/spf13/cobra" +) + +var ( + globalCtx context.Context + configPaths []string + configDirectories []string + workingDir string + disableColor bool +) + +var mainCommand = &cobra.Command{ + Use: "sing-box", + PersistentPreRun: preRun, +} + +func init() { + mainCommand.PersistentFlags().StringArrayVarP(&configPaths, "config", "c", nil, "set configuration file path") + mainCommand.PersistentFlags().StringArrayVarP(&configDirectories, "config-directory", "C", nil, "set configuration directory path") + mainCommand.PersistentFlags().StringVarP(&workingDir, "directory", "D", "", "set working directory") + mainCommand.PersistentFlags().BoolVarP(&disableColor, "disable-color", "", false, "disable color output") +} + +func preRun(cmd *cobra.Command, args []string) { + globalCtx = context.Background() + sudoUser := os.Getenv("SUDO_USER") + sudoUID, _ := strconv.Atoi(os.Getenv("SUDO_UID")) + sudoGID, _ := strconv.Atoi(os.Getenv("SUDO_GID")) + if sudoUID == 0 && sudoGID == 0 && sudoUser != "" { + sudoUserObject, _ := user.Lookup(sudoUser) + if sudoUserObject != nil { + sudoUID, _ = strconv.Atoi(sudoUserObject.Uid) + sudoGID, _ = strconv.Atoi(sudoUserObject.Gid) + } + } + if sudoUID > 0 && sudoGID > 0 { + globalCtx = filemanager.WithDefault(globalCtx, "", "", sudoUID, sudoGID) + } + if disableColor { + log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false).Logger()) + } + if workingDir != "" { + _, err := os.Stat(workingDir) + if err != nil { + filemanager.MkdirAll(globalCtx, workingDir, 0o777) + } + err = os.Chdir(workingDir) + if err != nil { + log.Fatal(err) + } + } + if len(configPaths) == 0 && len(configDirectories) == 0 { + configPaths = append(configPaths, "config.json") + } + globalCtx = include.Context(service.ContextWith(globalCtx, deprecated.NewStderrManager(log.StdLogger()))) +} diff --git a/cmd/sing-box/cmd_check.go b/cmd/sing-box/cmd_check.go new file mode 100644 index 00000000..29a39081 --- /dev/null +++ b/cmd/sing-box/cmd_check.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var commandCheck = &cobra.Command{ + Use: "check", + Short: "Check configuration", + Run: func(cmd *cobra.Command, args []string) { + err := check() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +func init() { + mainCommand.AddCommand(commandCheck) +} + +func check() error { + options, err := readConfigAndMerge() + if err != nil { + return err + } + ctx, cancel := context.WithCancel(globalCtx) + instance, err := box.New(box.Options{ + Context: ctx, + Options: options, + }) + if err == nil { + instance.Close() + } + cancel() + return err +} diff --git a/cmd/sing-box/cmd_format.go b/cmd/sing-box/cmd_format.go new file mode 100644 index 00000000..ab59c9ae --- /dev/null +++ b/cmd/sing-box/cmd_format.go @@ -0,0 +1,75 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + + "github.com/spf13/cobra" +) + +var commandFormatFlagWrite bool + +var commandFormat = &cobra.Command{ + Use: "format", + Short: "Format configuration", + Run: func(cmd *cobra.Command, args []string) { + err := format() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +func init() { + commandFormat.Flags().BoolVarP(&commandFormatFlagWrite, "write", "w", false, "write result to (source) file instead of stdout") + mainCommand.AddCommand(commandFormat) +} + +func format() error { + optionsList, err := readConfig() + if err != nil { + return err + } + for _, optionsEntry := range optionsList { + optionsEntry.options, err = badjson.Omitempty(globalCtx, optionsEntry.options) + if err != nil { + return err + } + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(optionsEntry.options) + if err != nil { + return E.Cause(err, "encode config") + } + outputPath, _ := filepath.Abs(optionsEntry.path) + if !commandFormatFlagWrite { + if len(optionsList) > 1 { + os.Stdout.WriteString(outputPath + "\n") + } + os.Stdout.WriteString(buffer.String() + "\n") + continue + } + if bytes.Equal(optionsEntry.content, buffer.Bytes()) { + continue + } + output, err := os.Create(optionsEntry.path) + if err != nil { + return E.Cause(err, "open output") + } + _, err = output.Write(buffer.Bytes()) + output.Close() + if err != nil { + return E.Cause(err, "write output") + } + os.Stderr.WriteString(outputPath + "\n") + } + return nil +} diff --git a/cmd/sing-box/cmd_generate.go b/cmd/sing-box/cmd_generate.go new file mode 100644 index 00000000..74d64c55 --- /dev/null +++ b/cmd/sing-box/cmd_generate.go @@ -0,0 +1,92 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "os" + "strconv" + + "github.com/sagernet/sing-box/log" + + "github.com/gofrs/uuid/v5" + "github.com/spf13/cobra" +) + +var commandGenerate = &cobra.Command{ + Use: "generate", + Short: "Generate things", +} + +func init() { + commandGenerate.AddCommand(commandGenerateUUID) + commandGenerate.AddCommand(commandGenerateRandom) + + mainCommand.AddCommand(commandGenerate) +} + +var ( + outputBase64 bool + outputHex bool +) + +var commandGenerateRandom = &cobra.Command{ + Use: "rand ", + Short: "Generate random bytes", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := generateRandom(args) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGenerateRandom.Flags().BoolVar(&outputBase64, "base64", false, "Generate base64 string") + commandGenerateRandom.Flags().BoolVar(&outputHex, "hex", false, "Generate hex string") +} + +func generateRandom(args []string) error { + length, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + + randomBytes := make([]byte, length) + _, err = rand.Read(randomBytes) + if err != nil { + return err + } + + if outputBase64 { + _, err = os.Stdout.WriteString(base64.StdEncoding.EncodeToString(randomBytes) + "\n") + } else if outputHex { + _, err = os.Stdout.WriteString(hex.EncodeToString(randomBytes) + "\n") + } else { + _, err = os.Stdout.Write(randomBytes) + } + + return err +} + +var commandGenerateUUID = &cobra.Command{ + Use: "uuid", + Short: "Generate UUID string", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := generateUUID() + if err != nil { + log.Fatal(err) + } + }, +} + +func generateUUID() error { + newUUID, err := uuid.NewV4() + if err != nil { + return err + } + _, err = os.Stdout.WriteString(newUUID.String() + "\n") + return err +} diff --git a/cmd/sing-box/cmd_generate_ech.go b/cmd/sing-box/cmd_generate_ech.go new file mode 100644 index 00000000..7942d588 --- /dev/null +++ b/cmd/sing-box/cmd_generate_ech.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var commandGenerateECHKeyPair = &cobra.Command{ + Use: "ech-keypair ", + Short: "Generate TLS ECH key pair", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := generateECHKeyPair(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGenerate.AddCommand(commandGenerateECHKeyPair) +} + +func generateECHKeyPair(serverName string) error { + configPem, keyPem, err := tls.ECHKeygenDefault(serverName) + if err != nil { + return err + } + os.Stdout.WriteString(configPem) + os.Stdout.WriteString(keyPem) + return nil +} diff --git a/cmd/sing-box/cmd_generate_tls.go b/cmd/sing-box/cmd_generate_tls.go new file mode 100644 index 00000000..4cd4060d --- /dev/null +++ b/cmd/sing-box/cmd_generate_tls.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + "time" + + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var flagGenerateTLSKeyPairMonths int + +var commandGenerateTLSKeyPair = &cobra.Command{ + Use: "tls-keypair ", + Short: "Generate TLS self sign key pair", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := generateTLSKeyPair(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGenerateTLSKeyPair.Flags().IntVarP(&flagGenerateTLSKeyPairMonths, "months", "m", 1, "Valid months") + commandGenerate.AddCommand(commandGenerateTLSKeyPair) +} + +func generateTLSKeyPair(serverName string) error { + privateKeyPem, publicKeyPem, err := tls.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().AddDate(0, flagGenerateTLSKeyPairMonths, 0)) + if err != nil { + return err + } + os.Stdout.WriteString(string(privateKeyPem) + "\n") + os.Stdout.WriteString(string(publicKeyPem) + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_generate_vapid.go b/cmd/sing-box/cmd_generate_vapid.go new file mode 100644 index 00000000..e83e6d80 --- /dev/null +++ b/cmd/sing-box/cmd_generate_vapid.go @@ -0,0 +1,40 @@ +//go:build go1.20 + +package main + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "os" + + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var commandGenerateVAPIDKeyPair = &cobra.Command{ + Use: "vapid-keypair", + Short: "Generate VAPID key pair", + Run: func(cmd *cobra.Command, args []string) { + err := generateVAPIDKeyPair() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGenerate.AddCommand(commandGenerateVAPIDKeyPair) +} + +func generateVAPIDKeyPair() error { + privateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return err + } + publicKey := privateKey.PublicKey() + os.Stdout.WriteString("PrivateKey: " + base64.RawURLEncoding.EncodeToString(privateKey.Bytes()) + "\n") + os.Stdout.WriteString("PublicKey: " + base64.RawURLEncoding.EncodeToString(publicKey.Bytes()) + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_generate_wireguard.go b/cmd/sing-box/cmd_generate_wireguard.go new file mode 100644 index 00000000..087e74b2 --- /dev/null +++ b/cmd/sing-box/cmd_generate_wireguard.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/base64" + "os" + + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func init() { + commandGenerate.AddCommand(commandGenerateWireGuardKeyPair) + commandGenerate.AddCommand(commandGenerateRealityKeyPair) +} + +var commandGenerateWireGuardKeyPair = &cobra.Command{ + Use: "wg-keypair", + Short: "Generate WireGuard key pair", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := generateWireGuardKey() + if err != nil { + log.Fatal(err) + } + }, +} + +func generateWireGuardKey() error { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return err + } + os.Stdout.WriteString("PrivateKey: " + privateKey.String() + "\n") + os.Stdout.WriteString("PublicKey: " + privateKey.PublicKey().String() + "\n") + return nil +} + +var commandGenerateRealityKeyPair = &cobra.Command{ + Use: "reality-keypair", + Short: "Generate reality key pair", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := generateRealityKey() + if err != nil { + log.Fatal(err) + } + }, +} + +func generateRealityKey() error { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return err + } + publicKey := privateKey.PublicKey() + os.Stdout.WriteString("PrivateKey: " + base64.RawURLEncoding.EncodeToString(privateKey[:]) + "\n") + os.Stdout.WriteString("PublicKey: " + base64.RawURLEncoding.EncodeToString(publicKey[:]) + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_geoip.go b/cmd/sing-box/cmd_geoip.go new file mode 100644 index 00000000..dbbbff13 --- /dev/null +++ b/cmd/sing-box/cmd_geoip.go @@ -0,0 +1,43 @@ +package main + +import ( + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/oschwald/maxminddb-golang" + "github.com/spf13/cobra" +) + +var ( + geoipReader *maxminddb.Reader + commandGeoIPFlagFile string +) + +var commandGeoip = &cobra.Command{ + Use: "geoip", + Short: "GeoIP tools", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + err := geoipPreRun() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoip.PersistentFlags().StringVarP(&commandGeoIPFlagFile, "file", "f", "geoip.db", "geoip file") + mainCommand.AddCommand(commandGeoip) +} + +func geoipPreRun() error { + reader, err := maxminddb.Open(commandGeoIPFlagFile) + if err != nil { + return err + } + if reader.Metadata.DatabaseType != "sing-geoip" { + reader.Close() + return E.New("incorrect database type, expected sing-geoip, got ", reader.Metadata.DatabaseType) + } + geoipReader = reader + return nil +} diff --git a/cmd/sing-box/cmd_geoip_export.go b/cmd/sing-box/cmd_geoip_export.go new file mode 100644 index 00000000..b80e5cd3 --- /dev/null +++ b/cmd/sing-box/cmd_geoip_export.go @@ -0,0 +1,98 @@ +package main + +import ( + "io" + "net" + "os" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + + "github.com/oschwald/maxminddb-golang" + "github.com/spf13/cobra" +) + +var flagGeoipExportOutput string + +const flagGeoipExportDefaultOutput = "geoip-.srs" + +var commandGeoipExport = &cobra.Command{ + Use: "export ", + Short: "Export geoip country as rule-set", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := geoipExport(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoipExport.Flags().StringVarP(&flagGeoipExportOutput, "output", "o", flagGeoipExportDefaultOutput, "Output path") + commandGeoip.AddCommand(commandGeoipExport) +} + +func geoipExport(countryCode string) error { + networks := geoipReader.Networks(maxminddb.SkipAliasedNetworks) + countryMap := make(map[string][]*net.IPNet) + var ( + ipNet *net.IPNet + nextCountryCode string + err error + ) + for networks.Next() { + ipNet, err = networks.Network(&nextCountryCode) + if err != nil { + return err + } + countryMap[nextCountryCode] = append(countryMap[nextCountryCode], ipNet) + } + ipNets := countryMap[strings.ToLower(countryCode)] + if len(ipNets) == 0 { + return E.New("country code not found: ", countryCode) + } + + var ( + outputFile *os.File + outputWriter io.Writer + ) + if flagGeoipExportOutput == "stdout" { + outputWriter = os.Stdout + } else if flagGeoipExportOutput == flagGeoipExportDefaultOutput { + outputFile, err = os.Create("geoip-" + countryCode + ".json") + if err != nil { + return err + } + defer outputFile.Close() + outputWriter = outputFile + } else { + outputFile, err = os.Create(flagGeoipExportOutput) + if err != nil { + return err + } + defer outputFile.Close() + outputWriter = outputFile + } + + encoder := json.NewEncoder(outputWriter) + encoder.SetIndent("", " ") + var headlessRule option.DefaultHeadlessRule + headlessRule.IPCIDR = make([]string, 0, len(ipNets)) + for _, cidr := range ipNets { + headlessRule.IPCIDR = append(headlessRule.IPCIDR, cidr.String()) + } + var plainRuleSet option.PlainRuleSetCompat + plainRuleSet.Version = C.RuleSetVersion2 + plainRuleSet.Options.Rules = []option.HeadlessRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: headlessRule, + }, + } + return encoder.Encode(plainRuleSet) +} diff --git a/cmd/sing-box/cmd_geoip_list.go b/cmd/sing-box/cmd_geoip_list.go new file mode 100644 index 00000000..54dd426e --- /dev/null +++ b/cmd/sing-box/cmd_geoip_list.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var commandGeoipList = &cobra.Command{ + Use: "list", + Short: "List geoip country codes", + Run: func(cmd *cobra.Command, args []string) { + err := listGeoip() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoip.AddCommand(commandGeoipList) +} + +func listGeoip() error { + for _, code := range geoipReader.Metadata.Languages { + os.Stdout.WriteString(code + "\n") + } + return nil +} diff --git a/cmd/sing-box/cmd_geoip_lookup.go b/cmd/sing-box/cmd_geoip_lookup.go new file mode 100644 index 00000000..d5157bb4 --- /dev/null +++ b/cmd/sing-box/cmd_geoip_lookup.go @@ -0,0 +1,47 @@ +package main + +import ( + "net/netip" + "os" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + + "github.com/spf13/cobra" +) + +var commandGeoipLookup = &cobra.Command{ + Use: "lookup
", + Short: "Lookup if an IP address is contained in the GeoIP database", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := geoipLookup(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoip.AddCommand(commandGeoipLookup) +} + +func geoipLookup(address string) error { + addr, err := netip.ParseAddr(address) + if err != nil { + return E.Cause(err, "parse address") + } + if !N.IsPublicAddr(addr) { + os.Stdout.WriteString("private\n") + return nil + } + var code string + _ = geoipReader.Lookup(addr.AsSlice(), &code) + if code != "" { + os.Stdout.WriteString(code + "\n") + return nil + } + os.Stdout.WriteString("unknown\n") + return nil +} diff --git a/cmd/sing-box/cmd_geosite.go b/cmd/sing-box/cmd_geosite.go new file mode 100644 index 00000000..95db9357 --- /dev/null +++ b/cmd/sing-box/cmd_geosite.go @@ -0,0 +1,41 @@ +package main + +import ( + "github.com/sagernet/sing-box/common/geosite" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + commandGeoSiteFlagFile string + geositeReader *geosite.Reader + geositeCodeList []string +) + +var commandGeoSite = &cobra.Command{ + Use: "geosite", + Short: "Geosite tools", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + err := geositePreRun() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoSite.PersistentFlags().StringVarP(&commandGeoSiteFlagFile, "file", "f", "geosite.db", "geosite file") + mainCommand.AddCommand(commandGeoSite) +} + +func geositePreRun() error { + reader, codeList, err := geosite.Open(commandGeoSiteFlagFile) + if err != nil { + return E.Cause(err, "open geosite file") + } + geositeReader = reader + geositeCodeList = codeList + return nil +} diff --git a/cmd/sing-box/cmd_geosite_export.go b/cmd/sing-box/cmd_geosite_export.go new file mode 100644 index 00000000..90a7955b --- /dev/null +++ b/cmd/sing-box/cmd_geosite_export.go @@ -0,0 +1,81 @@ +package main + +import ( + "io" + "os" + + "github.com/sagernet/sing-box/common/geosite" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json" + + "github.com/spf13/cobra" +) + +var commandGeositeExportOutput string + +const commandGeositeExportDefaultOutput = "geosite-.json" + +var commandGeositeExport = &cobra.Command{ + Use: "export ", + Short: "Export geosite category as rule-set", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := geositeExport(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeositeExport.Flags().StringVarP(&commandGeositeExportOutput, "output", "o", commandGeositeExportDefaultOutput, "Output path") + commandGeoSite.AddCommand(commandGeositeExport) +} + +func geositeExport(category string) error { + sourceSet, err := geositeReader.Read(category) + if err != nil { + return err + } + var ( + outputFile *os.File + outputWriter io.Writer + ) + if commandGeositeExportOutput == "stdout" { + outputWriter = os.Stdout + } else if commandGeositeExportOutput == commandGeositeExportDefaultOutput { + outputFile, err = os.Create("geosite-" + category + ".json") + if err != nil { + return err + } + defer outputFile.Close() + outputWriter = outputFile + } else { + outputFile, err = os.Create(commandGeositeExportOutput) + if err != nil { + return err + } + defer outputFile.Close() + outputWriter = outputFile + } + + encoder := json.NewEncoder(outputWriter) + encoder.SetIndent("", " ") + var headlessRule option.DefaultHeadlessRule + defaultRule := geosite.Compile(sourceSet) + headlessRule.Domain = defaultRule.Domain + headlessRule.DomainSuffix = defaultRule.DomainSuffix + headlessRule.DomainKeyword = defaultRule.DomainKeyword + headlessRule.DomainRegex = defaultRule.DomainRegex + var plainRuleSet option.PlainRuleSetCompat + plainRuleSet.Version = C.RuleSetVersion2 + plainRuleSet.Options.Rules = []option.HeadlessRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: headlessRule, + }, + } + return encoder.Encode(plainRuleSet) +} diff --git a/cmd/sing-box/cmd_geosite_list.go b/cmd/sing-box/cmd_geosite_list.go new file mode 100644 index 00000000..cedb7adf --- /dev/null +++ b/cmd/sing-box/cmd_geosite_list.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "sort" + + "github.com/sagernet/sing-box/log" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +var commandGeositeList = &cobra.Command{ + Use: "list ", + Short: "List geosite categories", + Run: func(cmd *cobra.Command, args []string) { + err := geositeList() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoSite.AddCommand(commandGeositeList) +} + +func geositeList() error { + var geositeEntry []struct { + category string + items int + } + for _, category := range geositeCodeList { + sourceSet, err := geositeReader.Read(category) + if err != nil { + return err + } + geositeEntry = append(geositeEntry, struct { + category string + items int + }{category, len(sourceSet)}) + } + sort.SliceStable(geositeEntry, func(i, j int) bool { + return geositeEntry[i].items < geositeEntry[j].items + }) + for _, entry := range geositeEntry { + os.Stdout.WriteString(F.ToString(entry.category, " (", entry.items, ")\n")) + } + return nil +} diff --git a/cmd/sing-box/cmd_geosite_lookup.go b/cmd/sing-box/cmd_geosite_lookup.go new file mode 100644 index 00000000..f648ce62 --- /dev/null +++ b/cmd/sing-box/cmd_geosite_lookup.go @@ -0,0 +1,97 @@ +package main + +import ( + "os" + "sort" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandGeositeLookup = &cobra.Command{ + Use: "lookup [category] ", + Short: "Check if a domain is in the geosite", + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + var ( + source string + target string + ) + switch len(args) { + case 1: + target = args[0] + case 2: + source = args[0] + target = args[1] + } + err := geositeLookup(source, target) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGeoSite.AddCommand(commandGeositeLookup) +} + +func geositeLookup(source string, target string) error { + var sourceMatcherList []struct { + code string + matcher *searchGeositeMatcher + } + if source != "" { + sourceSet, err := geositeReader.Read(source) + if err != nil { + return err + } + sourceMatcher, err := newSearchGeositeMatcher(sourceSet) + if err != nil { + return E.Cause(err, "compile code: "+source) + } + sourceMatcherList = []struct { + code string + matcher *searchGeositeMatcher + }{ + { + code: source, + matcher: sourceMatcher, + }, + } + + } else { + for _, code := range geositeCodeList { + sourceSet, err := geositeReader.Read(code) + if err != nil { + return err + } + sourceMatcher, err := newSearchGeositeMatcher(sourceSet) + if err != nil { + return E.Cause(err, "compile code: "+code) + } + sourceMatcherList = append(sourceMatcherList, struct { + code string + matcher *searchGeositeMatcher + }{ + code: code, + matcher: sourceMatcher, + }) + } + } + sort.SliceStable(sourceMatcherList, func(i, j int) bool { + return sourceMatcherList[i].code < sourceMatcherList[j].code + }) + + for _, matcherItem := range sourceMatcherList { + if matchRule := matcherItem.matcher.Match(target); matchRule != "" { + os.Stdout.WriteString("Match code (") + os.Stdout.WriteString(matcherItem.code) + os.Stdout.WriteString(") ") + os.Stdout.WriteString(matchRule) + os.Stdout.WriteString("\n") + } + } + return nil +} diff --git a/cmd/sing-box/cmd_geosite_matcher.go b/cmd/sing-box/cmd_geosite_matcher.go new file mode 100644 index 00000000..791dba24 --- /dev/null +++ b/cmd/sing-box/cmd_geosite_matcher.go @@ -0,0 +1,56 @@ +package main + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/common/geosite" +) + +type searchGeositeMatcher struct { + domainMap map[string]bool + suffixList []string + keywordList []string + regexList []string +} + +func newSearchGeositeMatcher(items []geosite.Item) (*searchGeositeMatcher, error) { + options := geosite.Compile(items) + domainMap := make(map[string]bool) + for _, domain := range options.Domain { + domainMap[domain] = true + } + rule := &searchGeositeMatcher{ + domainMap: domainMap, + suffixList: options.DomainSuffix, + keywordList: options.DomainKeyword, + regexList: options.DomainRegex, + } + return rule, nil +} + +func (r *searchGeositeMatcher) Match(domain string) string { + if r.domainMap[domain] { + return "domain=" + domain + } + for _, suffix := range r.suffixList { + if strings.HasSuffix(domain, suffix) { + return "domain_suffix=" + suffix + } + } + for _, keyword := range r.keywordList { + if strings.Contains(domain, keyword) { + return "domain_keyword=" + keyword + } + } + for _, regexStr := range r.regexList { + regex, err := regexp.Compile(regexStr) + if err != nil { + continue + } + if regex.MatchString(domain) { + return "domain_regex=" + regexStr + } + } + return "" +} diff --git a/cmd/sing-box/cmd_merge.go b/cmd/sing-box/cmd_merge.go new file mode 100644 index 00000000..6ca9a15a --- /dev/null +++ b/cmd/sing-box/cmd_merge.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/rw" + + "github.com/spf13/cobra" +) + +var commandMerge = &cobra.Command{ + Use: "merge ", + Short: "Merge configurations", + Run: func(cmd *cobra.Command, args []string) { + err := merge(args[0]) + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.ExactArgs(1), +} + +func init() { + mainCommand.AddCommand(commandMerge) +} + +func merge(outputPath string) error { + mergedOptions, err := readConfigAndMerge() + if err != nil { + return err + } + err = mergePathResources(&mergedOptions) + if err != nil { + return err + } + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(mergedOptions) + if err != nil { + return E.Cause(err, "encode config") + } + if existsContent, err := os.ReadFile(outputPath); err != nil { + if string(existsContent) == buffer.String() { + return nil + } + } + err = rw.MkdirParent(outputPath) + if err != nil { + return err + } + err = os.WriteFile(outputPath, buffer.Bytes(), 0o644) + if err != nil { + return err + } + outputPath, _ = filepath.Abs(outputPath) + os.Stderr.WriteString(outputPath + "\n") + return nil +} + +func mergePathResources(options *option.Options) error { + for _, inbound := range options.Inbounds { + if tlsOptions, containsTLSOptions := inbound.Options.(option.InboundTLSOptionsWrapper); containsTLSOptions { + tlsOptions.ReplaceInboundTLSOptions(mergeTLSInboundOptions(tlsOptions.TakeInboundTLSOptions())) + } + } + for _, outbound := range options.Outbounds { + switch outbound.Type { + case C.TypeSSH: + mergeSSHOutboundOptions(outbound.Options.(*option.SSHOutboundOptions)) + } + if tlsOptions, containsTLSOptions := outbound.Options.(option.OutboundTLSOptionsWrapper); containsTLSOptions { + tlsOptions.ReplaceOutboundTLSOptions(mergeTLSOutboundOptions(tlsOptions.TakeOutboundTLSOptions())) + } + } + return nil +} + +func mergeTLSInboundOptions(options *option.InboundTLSOptions) *option.InboundTLSOptions { + if options == nil { + return nil + } + if options.CertificatePath != "" { + if content, err := os.ReadFile(options.CertificatePath); err == nil { + options.Certificate = trimStringArray(strings.Split(string(content), "\n")) + } + } + if options.KeyPath != "" { + if content, err := os.ReadFile(options.KeyPath); err == nil { + options.Key = trimStringArray(strings.Split(string(content), "\n")) + } + } + if options.ECH != nil { + if options.ECH.KeyPath != "" { + if content, err := os.ReadFile(options.ECH.KeyPath); err == nil { + options.ECH.Key = trimStringArray(strings.Split(string(content), "\n")) + } + } + } + return options +} + +func mergeTLSOutboundOptions(options *option.OutboundTLSOptions) *option.OutboundTLSOptions { + if options == nil { + return nil + } + if options.CertificatePath != "" { + if content, err := os.ReadFile(options.CertificatePath); err == nil { + options.Certificate = trimStringArray(strings.Split(string(content), "\n")) + } + } + if options.ECH != nil { + if options.ECH.ConfigPath != "" { + if content, err := os.ReadFile(options.ECH.ConfigPath); err == nil { + options.ECH.Config = trimStringArray(strings.Split(string(content), "\n")) + } + } + } + return options +} + +func mergeSSHOutboundOptions(options *option.SSHOutboundOptions) { + if options.PrivateKeyPath != "" { + if content, err := os.ReadFile(os.ExpandEnv(options.PrivateKeyPath)); err == nil { + options.PrivateKey = trimStringArray(strings.Split(string(content), "\n")) + } + } +} + +func trimStringArray(array []string) []string { + return common.Filter(array, func(it string) bool { + return strings.TrimSpace(it) != "" + }) +} diff --git a/cmd/sing-box/cmd_rule_set.go b/cmd/sing-box/cmd_rule_set.go new file mode 100644 index 00000000..242ea8b6 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +var commandRuleSet = &cobra.Command{ + Use: "rule-set", + Short: "Manage rule-sets", +} + +func init() { + mainCommand.AddCommand(commandRuleSet) +} diff --git a/cmd/sing-box/cmd_rule_set_compile.go b/cmd/sing-box/cmd_rule_set_compile.go new file mode 100644 index 00000000..e2cbefc7 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_compile.go @@ -0,0 +1,102 @@ +package main + +import ( + "io" + "os" + "strings" + + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing/common/json" + + "github.com/spf13/cobra" +) + +var flagRuleSetCompileOutput string + +const flagRuleSetCompileDefaultOutput = ".srs" + +var commandRuleSetCompile = &cobra.Command{ + Use: "compile [source-path]", + Short: "Compile rule-set json to binary", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := compileRuleSet(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandRuleSet.AddCommand(commandRuleSetCompile) + commandRuleSetCompile.Flags().StringVarP(&flagRuleSetCompileOutput, "output", "o", flagRuleSetCompileDefaultOutput, "Output file") +} + +func compileRuleSet(sourcePath string) error { + var ( + reader io.Reader + err error + ) + if sourcePath == "stdin" { + reader = os.Stdin + } else { + reader, err = os.Open(sourcePath) + if err != nil { + return err + } + } + content, err := io.ReadAll(reader) + if err != nil { + return err + } + plainRuleSet, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + var outputPath string + if flagRuleSetCompileOutput == flagRuleSetCompileDefaultOutput { + if strings.HasSuffix(sourcePath, ".json") { + outputPath = sourcePath[:len(sourcePath)-5] + ".srs" + } else { + outputPath = sourcePath + ".srs" + } + } else { + outputPath = flagRuleSetCompileOutput + } + outputFile, err := os.Create(outputPath) + if err != nil { + return err + } + err = srs.Write(outputFile, plainRuleSet.Options, downgradeRuleSetVersion(plainRuleSet.Version, plainRuleSet.Options)) + if err != nil { + outputFile.Close() + os.Remove(outputPath) + return err + } + outputFile.Close() + return nil +} + +func downgradeRuleSetVersion(version uint8, options option.PlainRuleSet) uint8 { + if version == C.RuleSetVersion5 && !rule.HasHeadlessRule(options.Rules, func(rule option.DefaultHeadlessRule) bool { + return len(rule.PackageNameRegex) > 0 + }) { + version = C.RuleSetVersion4 + } + if version == C.RuleSetVersion4 && !rule.HasHeadlessRule(options.Rules, func(rule option.DefaultHeadlessRule) bool { + return rule.NetworkInterfaceAddress != nil && rule.NetworkInterfaceAddress.Size() > 0 || + len(rule.DefaultInterfaceAddress) > 0 + }) { + version = C.RuleSetVersion3 + } + if version == C.RuleSetVersion3 && !rule.HasHeadlessRule(options.Rules, func(rule option.DefaultHeadlessRule) bool { + return len(rule.NetworkType) > 0 || rule.NetworkIsExpensive || rule.NetworkIsConstrained + }) { + version = C.RuleSetVersion2 + } + return version +} diff --git a/cmd/sing-box/cmd_rule_set_convert.go b/cmd/sing-box/cmd_rule_set_convert.go new file mode 100644 index 00000000..f4f8d2ca --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_convert.go @@ -0,0 +1,89 @@ +package main + +import ( + "io" + "os" + "strings" + + "github.com/sagernet/sing-box/common/convertor/adguard" + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + flagRuleSetConvertType string + flagRuleSetConvertOutput string +) + +var commandRuleSetConvert = &cobra.Command{ + Use: "convert [source-path]", + Short: "Convert adguard DNS filter to rule-set", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := convertRuleSet(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandRuleSet.AddCommand(commandRuleSetConvert) + commandRuleSetConvert.Flags().StringVarP(&flagRuleSetConvertType, "type", "t", "", "Source type, available: adguard") + commandRuleSetConvert.Flags().StringVarP(&flagRuleSetConvertOutput, "output", "o", flagRuleSetCompileDefaultOutput, "Output file") +} + +func convertRuleSet(sourcePath string) error { + var ( + reader io.Reader + err error + ) + if sourcePath == "stdin" { + reader = os.Stdin + } else { + reader, err = os.Open(sourcePath) + if err != nil { + return err + } + } + var rules []option.HeadlessRule + switch flagRuleSetConvertType { + case "adguard": + rules, err = adguard.ToOptions(reader, log.StdLogger()) + case "": + return E.New("source type is required") + default: + return E.New("unsupported source type: ", flagRuleSetConvertType) + } + if err != nil { + return err + } + var outputPath string + if flagRuleSetConvertOutput == flagRuleSetCompileDefaultOutput { + if strings.HasSuffix(sourcePath, ".txt") { + outputPath = sourcePath[:len(sourcePath)-4] + ".srs" + } else { + outputPath = sourcePath + ".srs" + } + } else { + outputPath = flagRuleSetConvertOutput + } + outputFile, err := os.Create(outputPath) + if err != nil { + return err + } + defer outputFile.Close() + err = srs.Write(outputFile, option.PlainRuleSet{Rules: rules}, C.RuleSetVersion2) + if err != nil { + outputFile.Close() + os.Remove(outputPath) + return err + } + outputFile.Close() + return nil +} diff --git a/cmd/sing-box/cmd_rule_set_decompile.go b/cmd/sing-box/cmd_rule_set_decompile.go new file mode 100644 index 00000000..6fc43d42 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_decompile.go @@ -0,0 +1,101 @@ +package main + +import ( + "io" + "os" + "strings" + + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + + "github.com/spf13/cobra" +) + +var flagRuleSetDecompileOutput string + +const flagRuleSetDecompileDefaultOutput = ".json" + +var commandRuleSetDecompile = &cobra.Command{ + Use: "decompile [binary-path]", + Short: "Decompile rule-set binary to json", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := decompileRuleSet(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandRuleSet.AddCommand(commandRuleSetDecompile) + commandRuleSetDecompile.Flags().StringVarP(&flagRuleSetDecompileOutput, "output", "o", flagRuleSetDecompileDefaultOutput, "Output file") +} + +func decompileRuleSet(sourcePath string) error { + var ( + reader io.Reader + err error + ) + if sourcePath == "stdin" { + reader = os.Stdin + } else { + reader, err = os.Open(sourcePath) + if err != nil { + return err + } + } + ruleSet, err := srs.Read(reader, true) + if err != nil { + return err + } + if hasRule(ruleSet.Options.Rules, func(rule option.DefaultHeadlessRule) bool { + return len(rule.AdGuardDomain) > 0 + }) { + return E.New("unable to decompile binary AdGuard rules to rule-set.") + } + var outputPath string + if flagRuleSetDecompileOutput == flagRuleSetDecompileDefaultOutput { + if strings.HasSuffix(sourcePath, ".srs") { + outputPath = sourcePath[:len(sourcePath)-4] + ".json" + } else { + outputPath = sourcePath + ".json" + } + } else { + outputPath = flagRuleSetDecompileOutput + } + outputFile, err := os.Create(outputPath) + if err != nil { + return err + } + encoder := json.NewEncoder(outputFile) + encoder.SetIndent("", " ") + err = encoder.Encode(ruleSet) + if err != nil { + outputFile.Close() + os.Remove(outputPath) + return err + } + outputFile.Close() + return nil +} + +func hasRule(rules []option.HeadlessRule, cond func(rule option.DefaultHeadlessRule) bool) bool { + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if cond(rule.DefaultOptions) { + return true + } + case C.RuleTypeLogical: + if hasRule(rule.LogicalOptions.Rules, cond) { + return true + } + } + } + return false +} diff --git a/cmd/sing-box/cmd_rule_set_format.go b/cmd/sing-box/cmd_rule_set_format.go new file mode 100644 index 00000000..6276204c --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_format.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + + "github.com/spf13/cobra" +) + +var commandRuleSetFormatFlagWrite bool + +var commandRuleSetFormat = &cobra.Command{ + Use: "format ", + Short: "Format rule-set json", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := formatRuleSet(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandRuleSetFormat.Flags().BoolVarP(&commandRuleSetFormatFlagWrite, "write", "w", false, "write result to (source) file instead of stdout") + commandRuleSet.AddCommand(commandRuleSetFormat) +} + +func formatRuleSet(sourcePath string) error { + var ( + reader io.Reader + err error + ) + if sourcePath == "stdin" { + reader = os.Stdin + } else { + reader, err = os.Open(sourcePath) + if err != nil { + return err + } + } + content, err := io.ReadAll(reader) + if err != nil { + return err + } + plainRuleSet, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(plainRuleSet) + if err != nil { + return E.Cause(err, "encode config") + } + outputPath, _ := filepath.Abs(sourcePath) + if !commandRuleSetFormatFlagWrite || sourcePath == "stdin" { + os.Stdout.WriteString(buffer.String() + "\n") + return nil + } + if bytes.Equal(content, buffer.Bytes()) { + return nil + } + output, err := os.Create(sourcePath) + if err != nil { + return E.Cause(err, "open output") + } + _, err = output.Write(buffer.Bytes()) + output.Close() + if err != nil { + return E.Cause(err, "write output") + } + os.Stderr.WriteString(outputPath + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_rule_set_match.go b/cmd/sing-box/cmd_rule_set_match.go new file mode 100644 index 00000000..e3dc19b8 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_match.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + M "github.com/sagernet/sing/common/metadata" + + "github.com/spf13/cobra" +) + +var flagRuleSetMatchFormat string + +var commandRuleSetMatch = &cobra.Command{ + Use: "match ", + Short: "Check if an IP address or a domain matches the rule-set", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + err := ruleSetMatch(args[0], args[1]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandRuleSetMatch.Flags().StringVarP(&flagRuleSetMatchFormat, "format", "f", "source", "rule-set format") + commandRuleSet.AddCommand(commandRuleSetMatch) +} + +func ruleSetMatch(sourcePath string, domain string) error { + var ( + reader io.Reader + err error + ) + if sourcePath == "stdin" { + reader = os.Stdin + } else { + reader, err = os.Open(sourcePath) + if err != nil { + return E.Cause(err, "read rule-set") + } + } + content, err := io.ReadAll(reader) + if err != nil { + return E.Cause(err, "read rule-set") + } + if flagRuleSetMatchFormat == "" { + switch filepath.Ext(sourcePath) { + case ".json": + flagRuleSetMatchFormat = C.RuleSetFormatSource + case ".srs": + flagRuleSetMatchFormat = C.RuleSetFormatBinary + } + } + var ruleSet option.PlainRuleSetCompat + switch flagRuleSetMatchFormat { + case C.RuleSetFormatSource: + ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + case C.RuleSetFormatBinary: + ruleSet, err = srs.Read(bytes.NewReader(content), false) + if err != nil { + return err + } + default: + return E.New("unknown rule-set format: ", flagRuleSetMatchFormat) + } + plainRuleSet, err := ruleSet.Upgrade() + if err != nil { + return err + } + ipAddress := M.ParseAddr(domain) + var metadata adapter.InboundContext + if ipAddress.IsValid() { + metadata.Destination = M.SocksaddrFrom(ipAddress, 0) + } else { + metadata.Domain = domain + } + for i, ruleOptions := range plainRuleSet.Rules { + var currentRule adapter.HeadlessRule + currentRule, err = rule.NewHeadlessRule(context.Background(), ruleOptions) + if err != nil { + return E.Cause(err, "parse rule_set.rules.[", i, "]") + } + if currentRule.Match(&metadata) { + println(F.ToString("match rules.[", i, "]: ", currentRule)) + } + } + return nil +} diff --git a/cmd/sing-box/cmd_rule_set_merge.go b/cmd/sing-box/cmd_rule_set_merge.go new file mode 100644 index 00000000..7c8f7a53 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_merge.go @@ -0,0 +1,162 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/rw" + + "github.com/spf13/cobra" +) + +var ( + ruleSetPaths []string + ruleSetDirectories []string +) + +var commandRuleSetMerge = &cobra.Command{ + Use: "merge ", + Short: "Merge rule-set source files", + Run: func(cmd *cobra.Command, args []string) { + err := mergeRuleSet(args[0]) + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.ExactArgs(1), +} + +func init() { + commandRuleSetMerge.Flags().StringArrayVarP(&ruleSetPaths, "config", "c", nil, "set input rule-set file path") + commandRuleSetMerge.Flags().StringArrayVarP(&ruleSetDirectories, "config-directory", "C", nil, "set input rule-set directory path") + commandRuleSet.AddCommand(commandRuleSetMerge) +} + +type RuleSetEntry struct { + content []byte + path string + options option.PlainRuleSetCompat +} + +func readRuleSetAt(path string) (*RuleSetEntry, error) { + var ( + configContent []byte + err error + ) + if path == "stdin" { + configContent, err = io.ReadAll(os.Stdin) + } else { + configContent, err = os.ReadFile(path) + } + if err != nil { + return nil, E.Cause(err, "read config at ", path) + } + options, err := json.UnmarshalExtendedContext[option.PlainRuleSetCompat](globalCtx, configContent) + if err != nil { + return nil, E.Cause(err, "decode config at ", path) + } + return &RuleSetEntry{ + content: configContent, + path: path, + options: options, + }, nil +} + +func readRuleSet() ([]*RuleSetEntry, error) { + var optionsList []*RuleSetEntry + for _, path := range ruleSetPaths { + optionsEntry, err := readRuleSetAt(path) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + for _, directory := range ruleSetDirectories { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, E.Cause(err, "read rule-set directory at ", directory) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") || entry.IsDir() { + continue + } + optionsEntry, err := readRuleSetAt(filepath.Join(directory, entry.Name())) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + } + sort.Slice(optionsList, func(i, j int) bool { + return optionsList[i].path < optionsList[j].path + }) + return optionsList, nil +} + +func readRuleSetAndMerge() (option.PlainRuleSetCompat, error) { + optionsList, err := readRuleSet() + if err != nil { + return option.PlainRuleSetCompat{}, err + } + if len(optionsList) == 1 { + return optionsList[0].options, nil + } + var optionVersion uint8 + for _, options := range optionsList { + if optionVersion < options.options.Version { + optionVersion = options.options.Version + } + } + var mergedMessage json.RawMessage + for _, options := range optionsList { + mergedMessage, err = badjson.MergeJSON(globalCtx, options.options.RawMessage, mergedMessage, false) + if err != nil { + return option.PlainRuleSetCompat{}, E.Cause(err, "merge config at ", options.path) + } + } + mergedOptions, err := json.UnmarshalExtendedContext[option.PlainRuleSetCompat](globalCtx, mergedMessage) + if err != nil { + return option.PlainRuleSetCompat{}, E.Cause(err, "unmarshal merged config") + } + mergedOptions.Version = optionVersion + return mergedOptions, nil +} + +func mergeRuleSet(outputPath string) error { + mergedOptions, err := readRuleSetAndMerge() + if err != nil { + return err + } + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(mergedOptions) + if err != nil { + return E.Cause(err, "encode config") + } + if existsContent, err := os.ReadFile(outputPath); err != nil { + if string(existsContent) == buffer.String() { + return nil + } + } + err = rw.MkdirParent(outputPath) + if err != nil { + return err + } + err = os.WriteFile(outputPath, buffer.Bytes(), 0o644) + if err != nil { + return err + } + outputPath, _ = filepath.Abs(outputPath) + os.Stderr.WriteString(outputPath + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_rule_set_upgrade.go b/cmd/sing-box/cmd_rule_set_upgrade.go new file mode 100644 index 00000000..3d77a1d2 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_upgrade.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + + "github.com/spf13/cobra" +) + +var commandRuleSetUpgradeFlagWrite bool + +var commandRuleSetUpgrade = &cobra.Command{ + Use: "upgrade ", + Short: "Upgrade rule-set json", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := upgradeRuleSet(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandRuleSetUpgrade.Flags().BoolVarP(&commandRuleSetUpgradeFlagWrite, "write", "w", false, "write result to (source) file instead of stdout") + commandRuleSet.AddCommand(commandRuleSetUpgrade) +} + +func upgradeRuleSet(sourcePath string) error { + var ( + reader io.Reader + err error + ) + if sourcePath == "stdin" { + reader = os.Stdin + } else { + reader, err = os.Open(sourcePath) + if err != nil { + return err + } + } + content, err := io.ReadAll(reader) + if err != nil { + return err + } + plainRuleSetCompat, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + switch plainRuleSetCompat.Version { + case C.RuleSetVersion1: + default: + log.Info("already up-to-date") + return nil + } + plainRuleSetCompat.Options, err = plainRuleSetCompat.Upgrade() + if err != nil { + return err + } + plainRuleSetCompat.Version = C.RuleSetVersionCurrent + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(plainRuleSetCompat) + if err != nil { + return E.Cause(err, "encode config") + } + outputPath, _ := filepath.Abs(sourcePath) + if !commandRuleSetUpgradeFlagWrite || sourcePath == "stdin" { + os.Stdout.WriteString(buffer.String() + "\n") + return nil + } + if bytes.Equal(content, buffer.Bytes()) { + return nil + } + output, err := os.Create(sourcePath) + if err != nil { + return E.Cause(err, "open output") + } + _, err = output.Write(buffer.Bytes()) + output.Close() + if err != nil { + return E.Cause(err, "write output") + } + os.Stderr.WriteString(outputPath + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go new file mode 100644 index 00000000..f31db9dc --- /dev/null +++ b/cmd/sing-box/cmd_run.go @@ -0,0 +1,212 @@ +package main + +import ( + "context" + "io" + "os" + "os/signal" + "path/filepath" + runtimeDebug "runtime/debug" + "sort" + "strings" + "syscall" + "time" + + "github.com/sagernet/sing-box" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + + "github.com/spf13/cobra" +) + +var commandRun = &cobra.Command{ + Use: "run", + Short: "Run service", + Run: func(cmd *cobra.Command, args []string) { + err := run() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + mainCommand.AddCommand(commandRun) +} + +type OptionsEntry struct { + content []byte + path string + options option.Options +} + +func readConfigAt(path string) (*OptionsEntry, error) { + var ( + configContent []byte + err error + ) + if path == "stdin" { + configContent, err = io.ReadAll(os.Stdin) + } else { + configContent, err = os.ReadFile(path) + } + if err != nil { + return nil, E.Cause(err, "read config at ", path) + } + options, err := json.UnmarshalExtendedContext[option.Options](globalCtx, configContent) + if err != nil { + return nil, E.Cause(err, "decode config at ", path) + } + return &OptionsEntry{ + content: configContent, + path: path, + options: options, + }, nil +} + +func readConfig() ([]*OptionsEntry, error) { + var optionsList []*OptionsEntry + for _, path := range configPaths { + optionsEntry, err := readConfigAt(path) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + for _, directory := range configDirectories { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, E.Cause(err, "read config directory at ", directory) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") || entry.IsDir() { + continue + } + optionsEntry, err := readConfigAt(filepath.Join(directory, entry.Name())) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + } + sort.Slice(optionsList, func(i, j int) bool { + return optionsList[i].path < optionsList[j].path + }) + return optionsList, nil +} + +func readConfigAndMerge() (option.Options, error) { + optionsList, err := readConfig() + if err != nil { + return option.Options{}, err + } + if len(optionsList) == 1 { + return optionsList[0].options, nil + } + var mergedMessage json.RawMessage + for _, options := range optionsList { + mergedMessage, err = badjson.MergeJSON(globalCtx, options.options.RawMessage, mergedMessage, false) + if err != nil { + return option.Options{}, E.Cause(err, "merge config at ", options.path) + } + } + var mergedOptions option.Options + err = mergedOptions.UnmarshalJSONContext(globalCtx, mergedMessage) + if err != nil { + return option.Options{}, E.Cause(err, "unmarshal merged config") + } + return mergedOptions, nil +} + +func create() (*box.Box, context.CancelFunc, error) { + options, err := readConfigAndMerge() + if err != nil { + return nil, nil, err + } + if disableColor { + if options.Log == nil { + options.Log = &option.LogOptions{} + } + options.Log.DisableColor = true + } + ctx, cancel := context.WithCancel(globalCtx) + instance, err := box.New(box.Options{ + Context: ctx, + Options: options, + }) + if err != nil { + cancel() + return nil, nil, E.Cause(err, "create service") + } + + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer func() { + signal.Stop(osSignals) + close(osSignals) + }() + startCtx, finishStart := context.WithCancel(context.Background()) + go func() { + _, loaded := <-osSignals + if loaded { + cancel() + closeMonitor(startCtx) + } + }() + err = instance.Start() + finishStart() + if err != nil { + cancel() + return nil, nil, E.Cause(err, "start service") + } + return instance, cancel, nil +} + +func run() error { + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(osSignals) + for { + instance, cancel, err := create() + if err != nil { + return err + } + runtimeDebug.FreeOSMemory() + for { + osSignal := <-osSignals + if osSignal == syscall.SIGHUP { + err = check() + if err != nil { + log.Error(E.Cause(err, "reload service")) + continue + } + } + cancel() + closeCtx, closed := context.WithCancel(context.Background()) + go closeMonitor(closeCtx) + err = instance.Close() + closed() + if osSignal != syscall.SIGHUP { + if err != nil { + log.Error(E.Cause(err, "sing-box did not closed properly")) + } + return nil + } + break + } + } +} + +func closeMonitor(ctx context.Context) { + time.Sleep(C.FatalStopTimeout) + select { + case <-ctx.Done(): + return + default: + } + log.Fatal("sing-box did not close!") +} diff --git a/cmd/sing-box/cmd_tools.go b/cmd/sing-box/cmd_tools.go new file mode 100644 index 00000000..55e5b458 --- /dev/null +++ b/cmd/sing-box/cmd_tools.go @@ -0,0 +1,54 @@ +package main + +import ( + "errors" + "os" + + "github.com/sagernet/sing-box" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + + "github.com/spf13/cobra" +) + +var commandToolsFlagOutbound string + +var commandTools = &cobra.Command{ + Use: "tools", + Short: "Experimental tools", +} + +func init() { + commandTools.PersistentFlags().StringVarP(&commandToolsFlagOutbound, "outbound", "o", "", "Use specified tag instead of default outbound") + mainCommand.AddCommand(commandTools) +} + +func createPreStartedClient() (*box.Box, error) { + options, err := readConfigAndMerge() + if err != nil { + if !(errors.Is(err, os.ErrNotExist) && len(configDirectories) == 0 && len(configPaths) == 1) || configPaths[0] != "config.json" { + return nil, err + } + } + instance, err := box.New(box.Options{Context: globalCtx, Options: options}) + if err != nil { + return nil, E.Cause(err, "create service") + } + err = instance.PreStart() + if err != nil { + return nil, E.Cause(err, "start service") + } + return instance, nil +} + +func createDialer(instance *box.Box, outboundTag string) (N.Dialer, error) { + if outboundTag == "" { + return instance.Outbound().Default(), nil + } else { + outbound, loaded := instance.Outbound().Outbound(outboundTag) + if !loaded { + return nil, E.New("outbound not found: ", outboundTag) + } + return outbound, nil + } +} diff --git a/cmd/sing-box/cmd_tools_connect.go b/cmd/sing-box/cmd_tools_connect.go new file mode 100644 index 00000000..d352d533 --- /dev/null +++ b/cmd/sing-box/cmd_tools_connect.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "os" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/task" + + "github.com/spf13/cobra" +) + +var commandConnectFlagNetwork string + +var commandConnect = &cobra.Command{ + Use: "connect
", + Short: "Connect to an address", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := connect(args[0]) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandConnect.Flags().StringVarP(&commandConnectFlagNetwork, "network", "n", "tcp", "network type") + commandTools.AddCommand(commandConnect) +} + +func connect(address string) error { + switch N.NetworkName(commandConnectFlagNetwork) { + case N.NetworkTCP, N.NetworkUDP: + default: + return E.Cause(N.ErrUnknownNetwork, commandConnectFlagNetwork) + } + instance, err := createPreStartedClient() + if err != nil { + return err + } + defer instance.Close() + dialer, err := createDialer(instance, commandToolsFlagOutbound) + if err != nil { + return err + } + conn, err := dialer.DialContext(context.Background(), commandConnectFlagNetwork, M.ParseSocksaddr(address)) + if err != nil { + return E.Cause(err, "connect to server") + } + var group task.Group + group.Append("upload", func(ctx context.Context) error { + return common.Error(bufio.Copy(conn, os.Stdin)) + }) + group.Append("download", func(ctx context.Context) error { + return common.Error(bufio.Copy(os.Stdout, conn)) + }) + group.Cleanup(func() { + conn.Close() + }) + err = group.Run(context.Background()) + if E.IsClosed(err) { + log.Info(err) + } else { + log.Error(err) + } + return nil +} diff --git a/cmd/sing-box/cmd_tools_fetch.go b/cmd/sing-box/cmd_tools_fetch.go new file mode 100644 index 00000000..5ee3b875 --- /dev/null +++ b/cmd/sing-box/cmd_tools_fetch.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + + "github.com/spf13/cobra" +) + +var commandFetch = &cobra.Command{ + Use: "fetch", + Short: "Fetch an URL", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := fetch(args) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandTools.AddCommand(commandFetch) +} + +var ( + httpClient *http.Client + http3Client *http.Client +) + +func fetch(args []string) error { + instance, err := createPreStartedClient() + if err != nil { + return err + } + defer instance.Close() + httpClient = &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialer, err := createDialer(instance, commandToolsFlagOutbound) + if err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + ForceAttemptHTTP2: true, + }, + } + defer httpClient.CloseIdleConnections() + if C.WithQUIC { + err = initializeHTTP3Client(instance) + if err != nil { + return err + } + defer http3Client.CloseIdleConnections() + } + for _, urlString := range args { + var parsedURL *url.URL + parsedURL, err = url.Parse(urlString) + if err != nil { + return err + } + switch parsedURL.Scheme { + case "": + parsedURL.Scheme = "http" + fallthrough + case "http", "https": + err = fetchHTTP(httpClient, parsedURL) + if err != nil { + return err + } + case "http3": + if !C.WithQUIC { + return C.ErrQUICNotIncluded + } + parsedURL.Scheme = "https" + err = fetchHTTP(http3Client, parsedURL) + if err != nil { + return err + } + default: + return E.New("unsupported scheme: ", parsedURL.Scheme) + } + } + return nil +} + +func fetchHTTP(httpClient *http.Client, parsedURL *url.URL) error { + request, err := http.NewRequest("GET", parsedURL.String(), nil) + if err != nil { + return err + } + request.Header.Add("User-Agent", "curl/7.88.0") + response, err := httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + _, err = bufio.Copy(os.Stdout, response.Body) + if errors.Is(err, io.EOF) { + return nil + } + return err +} diff --git a/cmd/sing-box/cmd_tools_fetch_http3.go b/cmd/sing-box/cmd_tools_fetch_http3.go new file mode 100644 index 00000000..3caa1e88 --- /dev/null +++ b/cmd/sing-box/cmd_tools_fetch_http3.go @@ -0,0 +1,36 @@ +//go:build with_quic + +package main + +import ( + "context" + "crypto/tls" + "net/http" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func initializeHTTP3Client(instance *box.Box) error { + dialer, err := createDialer(instance, commandToolsFlagOutbound) + if err != nil { + return err + } + http3Client = &http.Client{ + Transport: &http3.Transport{ + Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) { + destination := M.ParseSocksaddr(addr) + udpConn, dErr := dialer.DialContext(ctx, N.NetworkUDP, destination) + if dErr != nil { + return nil, dErr + } + return quic.DialEarly(ctx, bufio.NewUnbindPacketConn(udpConn), udpConn.RemoteAddr(), tlsCfg, cfg) + }, + }, + } + return nil +} diff --git a/cmd/sing-box/cmd_tools_fetch_http3_stub.go b/cmd/sing-box/cmd_tools_fetch_http3_stub.go new file mode 100644 index 00000000..ae13f54c --- /dev/null +++ b/cmd/sing-box/cmd_tools_fetch_http3_stub.go @@ -0,0 +1,18 @@ +//go:build !with_quic + +package main + +import ( + "net/url" + "os" + + box "github.com/sagernet/sing-box" +) + +func initializeHTTP3Client(instance *box.Box) error { + return os.ErrInvalid +} + +func fetchHTTP3(parsedURL *url.URL) error { + return os.ErrInvalid +} diff --git a/cmd/sing-box/cmd_tools_networkquality.go b/cmd/sing-box/cmd_tools_networkquality.go new file mode 100644 index 00000000..5f63571d --- /dev/null +++ b/cmd/sing-box/cmd_tools_networkquality.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/common/networkquality" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var ( + commandNetworkQualityFlagConfigURL string + commandNetworkQualityFlagSerial bool + commandNetworkQualityFlagMaxRuntime int + commandNetworkQualityFlagHTTP3 bool +) + +var commandNetworkQuality = &cobra.Command{ + Use: "networkquality", + Short: "Run a network quality test", + Run: func(cmd *cobra.Command, args []string) { + err := runNetworkQuality() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandNetworkQuality.Flags().StringVar( + &commandNetworkQualityFlagConfigURL, + "config-url", "", + "Network quality test config URL (default: Apple mensura)", + ) + commandNetworkQuality.Flags().BoolVar( + &commandNetworkQualityFlagSerial, + "serial", false, + "Run download and upload tests sequentially instead of in parallel", + ) + commandNetworkQuality.Flags().IntVar( + &commandNetworkQualityFlagMaxRuntime, + "max-runtime", int(networkquality.DefaultMaxRuntime/time.Second), + "Network quality maximum runtime in seconds", + ) + commandNetworkQuality.Flags().BoolVar( + &commandNetworkQualityFlagHTTP3, + "http3", false, + "Use HTTP/3 (QUIC) for measurement traffic", + ) + commandTools.AddCommand(commandNetworkQuality) +} + +func runNetworkQuality() error { + instance, err := createPreStartedClient() + if err != nil { + return err + } + defer instance.Close() + + dialer, err := createDialer(instance, commandToolsFlagOutbound) + if err != nil { + return err + } + + httpClient := networkquality.NewHTTPClient(dialer) + defer httpClient.CloseIdleConnections() + + measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(dialer, commandNetworkQualityFlagHTTP3) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr, "==== NETWORK QUALITY TEST ====") + + result, err := networkquality.Run(networkquality.Options{ + ConfigURL: commandNetworkQualityFlagConfigURL, + HTTPClient: httpClient, + NewMeasurementClient: measurementClientFactory, + Serial: commandNetworkQualityFlagSerial, + MaxRuntime: time.Duration(commandNetworkQualityFlagMaxRuntime) * time.Second, + Context: globalCtx, + OnProgress: func(p networkquality.Progress) { + if !commandNetworkQualityFlagSerial && p.Phase != networkquality.PhaseIdle { + fmt.Fprintf(os.Stderr, "\rDownload: %s RPM: %d Upload: %s RPM: %d", + networkquality.FormatBitrate(p.DownloadCapacity), p.DownloadRPM, + networkquality.FormatBitrate(p.UploadCapacity), p.UploadRPM) + return + } + switch networkquality.Phase(p.Phase) { + case networkquality.PhaseIdle: + if p.IdleLatencyMs > 0 { + fmt.Fprintf(os.Stderr, "\rIdle Latency: %d ms", p.IdleLatencyMs) + } else { + fmt.Fprint(os.Stderr, "\rMeasuring idle latency...") + } + case networkquality.PhaseDownload: + fmt.Fprintf(os.Stderr, "\rDownload: %s RPM: %d", + networkquality.FormatBitrate(p.DownloadCapacity), p.DownloadRPM) + case networkquality.PhaseUpload: + fmt.Fprintf(os.Stderr, "\rUpload: %s RPM: %d", + networkquality.FormatBitrate(p.UploadCapacity), p.UploadRPM) + } + }, + }) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, strings.Repeat("-", 40)) + fmt.Fprintf(os.Stderr, "Idle Latency: %d ms\n", result.IdleLatencyMs) + fmt.Fprintf(os.Stderr, "Download Capacity: %-20s Accuracy: %s\n", networkquality.FormatBitrate(result.DownloadCapacity), result.DownloadCapacityAccuracy) + fmt.Fprintf(os.Stderr, "Upload Capacity: %-20s Accuracy: %s\n", networkquality.FormatBitrate(result.UploadCapacity), result.UploadCapacityAccuracy) + fmt.Fprintf(os.Stderr, "Download Responsiveness: %-20s Accuracy: %s\n", fmt.Sprintf("%d RPM", result.DownloadRPM), result.DownloadRPMAccuracy) + fmt.Fprintf(os.Stderr, "Upload Responsiveness: %-20s Accuracy: %s\n", fmt.Sprintf("%d RPM", result.UploadRPM), result.UploadRPMAccuracy) + return nil +} diff --git a/cmd/sing-box/cmd_tools_stun.go b/cmd/sing-box/cmd_tools_stun.go new file mode 100644 index 00000000..f13086ca --- /dev/null +++ b/cmd/sing-box/cmd_tools_stun.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "os" + + "github.com/sagernet/sing-box/common/stun" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var commandSTUNFlagServer string + +var commandSTUN = &cobra.Command{ + Use: "stun", + Short: "Run a STUN test", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := runSTUN() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandSTUN.Flags().StringVarP(&commandSTUNFlagServer, "server", "s", stun.DefaultServer, "STUN server address") + commandTools.AddCommand(commandSTUN) +} + +func runSTUN() error { + instance, err := createPreStartedClient() + if err != nil { + return err + } + defer instance.Close() + + dialer, err := createDialer(instance, commandToolsFlagOutbound) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr, "==== STUN TEST ====") + + result, err := stun.Run(stun.Options{ + Server: commandSTUNFlagServer, + Dialer: dialer, + Context: globalCtx, + OnProgress: func(p stun.Progress) { + switch p.Phase { + case stun.PhaseBinding: + if p.ExternalAddr != "" { + fmt.Fprintf(os.Stderr, "\rExternal Address: %s (%d ms)", p.ExternalAddr, p.LatencyMs) + } else { + fmt.Fprint(os.Stderr, "\rSending binding request...") + } + case stun.PhaseNATMapping: + fmt.Fprint(os.Stderr, "\rDetecting NAT mapping behavior...") + case stun.PhaseNATFiltering: + fmt.Fprint(os.Stderr, "\rDetecting NAT filtering behavior...") + } + }, + }) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, "External Address: %s\n", result.ExternalAddr) + fmt.Fprintf(os.Stderr, "Latency: %d ms\n", result.LatencyMs) + if result.NATTypeSupported { + fmt.Fprintf(os.Stderr, "NAT Mapping: %s\n", result.NATMapping) + fmt.Fprintf(os.Stderr, "NAT Filtering: %s\n", result.NATFiltering) + } else { + fmt.Fprintln(os.Stderr, "NAT Type Detection: not supported by server") + } + return nil +} diff --git a/cmd/sing-box/cmd_tools_synctime.go b/cmd/sing-box/cmd_tools_synctime.go new file mode 100644 index 00000000..09d487ef --- /dev/null +++ b/cmd/sing-box/cmd_tools_synctime.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "os" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/ntp" + + "github.com/spf13/cobra" +) + +var ( + commandSyncTimeFlagServer string + commandSyncTimeOutputFormat string + commandSyncTimeWrite bool +) + +var commandSyncTime = &cobra.Command{ + Use: "synctime", + Short: "Sync time using the NTP protocol", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := syncTime() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandSyncTime.Flags().StringVarP(&commandSyncTimeFlagServer, "server", "s", "time.apple.com", "Set NTP server") + commandSyncTime.Flags().StringVarP(&commandSyncTimeOutputFormat, "format", "f", C.TimeLayout, "Set output format") + commandSyncTime.Flags().BoolVarP(&commandSyncTimeWrite, "write", "w", false, "Write time to system") + commandTools.AddCommand(commandSyncTime) +} + +func syncTime() error { + instance, err := createPreStartedClient() + if err != nil { + return err + } + dialer, err := createDialer(instance, commandToolsFlagOutbound) + if err != nil { + return err + } + defer instance.Close() + serverAddress := M.ParseSocksaddr(commandSyncTimeFlagServer) + if serverAddress.Port == 0 { + serverAddress.Port = 123 + } + response, err := ntp.Exchange(context.Background(), dialer, serverAddress) + if err != nil { + return err + } + if commandSyncTimeWrite { + err = ntp.SetSystemTime(response.Time) + if err != nil { + return E.Cause(err, "write time to system") + } + } + os.Stdout.WriteString(response.Time.Local().Format(commandSyncTimeOutputFormat)) + return nil +} diff --git a/cmd/sing-box/cmd_version.go b/cmd/sing-box/cmd_version.go new file mode 100644 index 00000000..ab5ada2e --- /dev/null +++ b/cmd/sing-box/cmd_version.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "runtime" + "runtime/debug" + + C "github.com/sagernet/sing-box/constant" + + "github.com/spf13/cobra" +) + +var commandVersion = &cobra.Command{ + Use: "version", + Short: "Print current version of sing-box", + Run: printVersion, + Args: cobra.NoArgs, +} + +var nameOnly bool + +func init() { + commandVersion.Flags().BoolVarP(&nameOnly, "name", "n", false, "print version name only") + mainCommand.AddCommand(commandVersion) +} + +func printVersion(cmd *cobra.Command, args []string) { + if nameOnly { + os.Stdout.WriteString(C.Version + "\n") + return + } + version := "sing-box version " + C.Version + "\n\n" + version += "Environment: " + runtime.Version() + " " + runtime.GOOS + "/" + runtime.GOARCH + "\n" + + var tags string + var revision string + + debugInfo, loaded := debug.ReadBuildInfo() + if loaded { + for _, setting := range debugInfo.Settings { + switch setting.Key { + case "-tags": + tags = setting.Value + case "vcs.revision": + revision = setting.Value + } + } + } + + if tags != "" { + version += "Tags: " + tags + "\n" + } + if revision != "" { + version += "Revision: " + revision + "\n" + } + + if C.CGO_ENABLED { + version += "CGO: enabled\n" + } else { + version += "CGO: disabled\n" + } + + os.Stdout.WriteString(version) +} diff --git a/cmd/sing-box/generate_completions.go b/cmd/sing-box/generate_completions.go new file mode 100644 index 00000000..6ab0cade --- /dev/null +++ b/cmd/sing-box/generate_completions.go @@ -0,0 +1,28 @@ +//go:build generate && generate_completions + +package main + +import "github.com/sagernet/sing-box/log" + +func main() { + err := generateCompletions() + if err != nil { + log.Fatal(err) + } +} + +func generateCompletions() error { + err := mainCommand.GenBashCompletionFile("release/completions/sing-box.bash") + if err != nil { + return err + } + err = mainCommand.GenFishCompletionFile("release/completions/sing-box.fish", true) + if err != nil { + return err + } + err = mainCommand.GenZshCompletionFile("release/completions/sing-box.zsh") + if err != nil { + return err + } + return nil +} diff --git a/cmd/sing-box/main.go b/cmd/sing-box/main.go new file mode 100644 index 00000000..fd55c7d3 --- /dev/null +++ b/cmd/sing-box/main.go @@ -0,0 +1,11 @@ +//go:build !generate + +package main + +import "github.com/sagernet/sing-box/log" + +func main() { + if err := mainCommand.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/common/badtls/raw_conn.go b/common/badtls/raw_conn.go new file mode 100644 index 00000000..774e39a5 --- /dev/null +++ b/common/badtls/raw_conn.go @@ -0,0 +1,176 @@ +//go:build go1.25 && badlinkname + +package badtls + +import ( + "bytes" + "os" + "reflect" + "sync/atomic" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/tls" +) + +type RawConn struct { + pointer unsafe.Pointer + methods *Methods + + IsClient *bool + IsHandshakeComplete *atomic.Bool + Vers *uint16 + CipherSuite *uint16 + + RawInput *bytes.Buffer + Input *bytes.Reader + Hand *bytes.Buffer + + CloseNotifySent *bool + CloseNotifyErr *error + + In *RawHalfConn + Out *RawHalfConn + + BytesSent *int64 + PacketsSent *int64 + + ActiveCall *atomic.Int32 + Tmp *[16]byte +} + +func NewRawConn(rawTLSConn tls.Conn) (*RawConn, error) { + var ( + pointer unsafe.Pointer + methods *Methods + loaded bool + ) + for _, tlsCreator := range methodRegistry { + pointer, methods, loaded = tlsCreator(rawTLSConn) + if loaded { + break + } + } + if !loaded { + return nil, os.ErrInvalid + } + + conn := &RawConn{ + pointer: pointer, + methods: methods, + } + + rawConn := reflect.Indirect(reflect.ValueOf(rawTLSConn)) + + rawIsClient := rawConn.FieldByName("isClient") + if !rawIsClient.IsValid() || rawIsClient.Kind() != reflect.Bool { + return nil, E.New("invalid Conn.isClient") + } + conn.IsClient = (*bool)(unsafe.Pointer(rawIsClient.UnsafeAddr())) + + rawIsHandshakeComplete := rawConn.FieldByName("isHandshakeComplete") + if !rawIsHandshakeComplete.IsValid() || rawIsHandshakeComplete.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.isHandshakeComplete") + } + conn.IsHandshakeComplete = (*atomic.Bool)(unsafe.Pointer(rawIsHandshakeComplete.UnsafeAddr())) + + rawVers := rawConn.FieldByName("vers") + if !rawVers.IsValid() || rawVers.Kind() != reflect.Uint16 { + return nil, E.New("invalid Conn.vers") + } + conn.Vers = (*uint16)(unsafe.Pointer(rawVers.UnsafeAddr())) + + rawCipherSuite := rawConn.FieldByName("cipherSuite") + if !rawCipherSuite.IsValid() || rawCipherSuite.Kind() != reflect.Uint16 { + return nil, E.New("invalid Conn.cipherSuite") + } + conn.CipherSuite = (*uint16)(unsafe.Pointer(rawCipherSuite.UnsafeAddr())) + + rawRawInput := rawConn.FieldByName("rawInput") + if !rawRawInput.IsValid() || rawRawInput.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.rawInput") + } + conn.RawInput = (*bytes.Buffer)(unsafe.Pointer(rawRawInput.UnsafeAddr())) + + rawInput := rawConn.FieldByName("input") + if !rawInput.IsValid() || rawInput.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.input") + } + conn.Input = (*bytes.Reader)(unsafe.Pointer(rawInput.UnsafeAddr())) + + rawHand := rawConn.FieldByName("hand") + if !rawHand.IsValid() || rawHand.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.hand") + } + conn.Hand = (*bytes.Buffer)(unsafe.Pointer(rawHand.UnsafeAddr())) + + rawCloseNotifySent := rawConn.FieldByName("closeNotifySent") + if !rawCloseNotifySent.IsValid() || rawCloseNotifySent.Kind() != reflect.Bool { + return nil, E.New("invalid Conn.closeNotifySent") + } + conn.CloseNotifySent = (*bool)(unsafe.Pointer(rawCloseNotifySent.UnsafeAddr())) + + rawCloseNotifyErr := rawConn.FieldByName("closeNotifyErr") + if !rawCloseNotifyErr.IsValid() || rawCloseNotifyErr.Kind() != reflect.Interface { + return nil, E.New("invalid Conn.closeNotifyErr") + } + conn.CloseNotifyErr = (*error)(unsafe.Pointer(rawCloseNotifyErr.UnsafeAddr())) + + rawIn := rawConn.FieldByName("in") + if !rawIn.IsValid() || rawIn.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.in") + } + halfIn, err := NewRawHalfConn(rawIn, methods) + if err != nil { + return nil, E.Cause(err, "invalid Conn.in") + } + conn.In = halfIn + + rawOut := rawConn.FieldByName("out") + if !rawOut.IsValid() || rawOut.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.out") + } + halfOut, err := NewRawHalfConn(rawOut, methods) + if err != nil { + return nil, E.Cause(err, "invalid Conn.out") + } + conn.Out = halfOut + + rawBytesSent := rawConn.FieldByName("bytesSent") + if !rawBytesSent.IsValid() || rawBytesSent.Kind() != reflect.Int64 { + return nil, E.New("invalid Conn.bytesSent") + } + conn.BytesSent = (*int64)(unsafe.Pointer(rawBytesSent.UnsafeAddr())) + + rawPacketsSent := rawConn.FieldByName("packetsSent") + if !rawPacketsSent.IsValid() || rawPacketsSent.Kind() != reflect.Int64 { + return nil, E.New("invalid Conn.packetsSent") + } + conn.PacketsSent = (*int64)(unsafe.Pointer(rawPacketsSent.UnsafeAddr())) + + rawActiveCall := rawConn.FieldByName("activeCall") + if !rawActiveCall.IsValid() || rawActiveCall.Kind() != reflect.Struct { + return nil, E.New("invalid Conn.activeCall") + } + conn.ActiveCall = (*atomic.Int32)(unsafe.Pointer(rawActiveCall.UnsafeAddr())) + + rawTmp := rawConn.FieldByName("tmp") + if !rawTmp.IsValid() || rawTmp.Kind() != reflect.Array || rawTmp.Len() != 16 || rawTmp.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("invalid Conn.tmp") + } + conn.Tmp = (*[16]byte)(unsafe.Pointer(rawTmp.UnsafeAddr())) + + return conn, nil +} + +func (c *RawConn) ReadRecord() error { + return c.methods.readRecord(c.pointer) +} + +func (c *RawConn) HandlePostHandshakeMessage() error { + return c.methods.handlePostHandshakeMessage(c.pointer) +} + +func (c *RawConn) WriteRecordLocked(typ uint16, data []byte) (int, error) { + return c.methods.writeRecordLocked(c.pointer, typ, data) +} diff --git a/common/badtls/raw_half_conn.go b/common/badtls/raw_half_conn.go new file mode 100644 index 00000000..4d2c8b64 --- /dev/null +++ b/common/badtls/raw_half_conn.go @@ -0,0 +1,121 @@ +//go:build go1.25 && badlinkname + +package badtls + +import ( + "hash" + "reflect" + "sync" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" +) + +type RawHalfConn struct { + pointer unsafe.Pointer + methods *Methods + *sync.Mutex + Err *error + Version *uint16 + Cipher *any + Seq *[8]byte + ScratchBuf *[13]byte + TrafficSecret *[]byte + Mac *hash.Hash + RawKey *[]byte + RawIV *[]byte + RawMac *[]byte +} + +func NewRawHalfConn(rawHalfConn reflect.Value, methods *Methods) (*RawHalfConn, error) { + halfConn := &RawHalfConn{ + pointer: (unsafe.Pointer)(rawHalfConn.UnsafeAddr()), + methods: methods, + } + + rawMutex := rawHalfConn.FieldByName("Mutex") + if !rawMutex.IsValid() || rawMutex.Kind() != reflect.Struct { + return nil, E.New("badtls: invalid halfConn.Mutex") + } + halfConn.Mutex = (*sync.Mutex)(unsafe.Pointer(rawMutex.UnsafeAddr())) + + rawErr := rawHalfConn.FieldByName("err") + if !rawErr.IsValid() || rawErr.Kind() != reflect.Interface { + return nil, E.New("badtls: invalid halfConn.err") + } + halfConn.Err = (*error)(unsafe.Pointer(rawErr.UnsafeAddr())) + + rawVersion := rawHalfConn.FieldByName("version") + if !rawVersion.IsValid() || rawVersion.Kind() != reflect.Uint16 { + return nil, E.New("badtls: invalid halfConn.version") + } + halfConn.Version = (*uint16)(unsafe.Pointer(rawVersion.UnsafeAddr())) + + rawCipher := rawHalfConn.FieldByName("cipher") + if !rawCipher.IsValid() || rawCipher.Kind() != reflect.Interface { + return nil, E.New("badtls: invalid halfConn.cipher") + } + halfConn.Cipher = (*any)(unsafe.Pointer(rawCipher.UnsafeAddr())) + + rawSeq := rawHalfConn.FieldByName("seq") + if !rawSeq.IsValid() || rawSeq.Kind() != reflect.Array || rawSeq.Len() != 8 || rawSeq.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("badtls: invalid halfConn.seq") + } + halfConn.Seq = (*[8]byte)(unsafe.Pointer(rawSeq.UnsafeAddr())) + + rawScratchBuf := rawHalfConn.FieldByName("scratchBuf") + if !rawScratchBuf.IsValid() || rawScratchBuf.Kind() != reflect.Array || rawScratchBuf.Len() != 13 || rawScratchBuf.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("badtls: invalid halfConn.scratchBuf") + } + halfConn.ScratchBuf = (*[13]byte)(unsafe.Pointer(rawScratchBuf.UnsafeAddr())) + + rawTrafficSecret := rawHalfConn.FieldByName("trafficSecret") + if !rawTrafficSecret.IsValid() || rawTrafficSecret.Kind() != reflect.Slice || rawTrafficSecret.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("badtls: invalid halfConn.trafficSecret") + } + halfConn.TrafficSecret = (*[]byte)(unsafe.Pointer(rawTrafficSecret.UnsafeAddr())) + + rawMac := rawHalfConn.FieldByName("mac") + if !rawMac.IsValid() || rawMac.Kind() != reflect.Interface { + return nil, E.New("badtls: invalid halfConn.mac") + } + halfConn.Mac = (*hash.Hash)(unsafe.Pointer(rawMac.UnsafeAddr())) + + rawKey := rawHalfConn.FieldByName("rawKey") + if rawKey.IsValid() { + if /*!rawKey.IsValid() || */ rawKey.Kind() != reflect.Slice || rawKey.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("badtls: invalid halfConn.rawKey") + } + halfConn.RawKey = (*[]byte)(unsafe.Pointer(rawKey.UnsafeAddr())) + + rawIV := rawHalfConn.FieldByName("rawIV") + if !rawIV.IsValid() || rawIV.Kind() != reflect.Slice || rawIV.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("badtls: invalid halfConn.rawIV") + } + halfConn.RawIV = (*[]byte)(unsafe.Pointer(rawIV.UnsafeAddr())) + + rawMAC := rawHalfConn.FieldByName("rawMac") + if !rawMAC.IsValid() || rawMAC.Kind() != reflect.Slice || rawMAC.Type().Elem().Kind() != reflect.Uint8 { + return nil, E.New("badtls: invalid halfConn.rawMac") + } + halfConn.RawMac = (*[]byte)(unsafe.Pointer(rawMAC.UnsafeAddr())) + } + + return halfConn, nil +} + +func (hc *RawHalfConn) Decrypt(record []byte) ([]byte, uint8, error) { + return hc.methods.decrypt(hc.pointer, record) +} + +func (hc *RawHalfConn) SetErrorLocked(err error) error { + return hc.methods.setErrorLocked(hc.pointer, err) +} + +func (hc *RawHalfConn) SetTrafficSecret(suite unsafe.Pointer, level int, secret []byte) { + hc.methods.setTrafficSecret(hc.pointer, suite, level, secret) +} + +func (hc *RawHalfConn) ExplicitNonceLen() int { + return hc.methods.explicitNonceLen(hc.pointer) +} diff --git a/common/badtls/read_wait.go b/common/badtls/read_wait.go new file mode 100644 index 00000000..8448b1a2 --- /dev/null +++ b/common/badtls/read_wait.go @@ -0,0 +1,82 @@ +//go:build go1.25 && badlinkname + +package badtls + +import ( + "github.com/sagernet/sing/common/buf" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/tls" +) + +var _ N.ReadWaiter = (*ReadWaitConn)(nil) + +type ReadWaitConn struct { + tls.Conn + rawConn *RawConn + readWaitOptions N.ReadWaitOptions +} + +func NewReadWaitConn(conn tls.Conn) (tls.Conn, error) { + if _, isReadWaitConn := conn.(N.ReadWaiter); isReadWaitConn { + return conn, nil + } + rawConn, err := NewRawConn(conn) + if err != nil { + return nil, err + } + return &ReadWaitConn{ + Conn: conn, + rawConn: rawConn, + }, nil +} + +func (c *ReadWaitConn) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) { + c.readWaitOptions = options + return false +} + +func (c *ReadWaitConn) WaitReadBuffer() (buffer *buf.Buffer, err error) { + //err = c.HandshakeContext(context.Background()) + //if err != nil { + // return + //} + c.rawConn.In.Lock() + defer c.rawConn.In.Unlock() + for c.rawConn.Input.Len() == 0 { + err = c.rawConn.ReadRecord() + if err != nil { + return + } + for c.rawConn.Hand.Len() > 0 { + err = c.rawConn.HandlePostHandshakeMessage() + if err != nil { + return + } + } + } + buffer = c.readWaitOptions.NewBuffer() + n, err := c.rawConn.Input.Read(buffer.FreeBytes()) + if err != nil { + buffer.Release() + return + } + buffer.Truncate(n) + + if n != 0 && c.rawConn.Input.Len() == 0 && c.rawConn.Input.Len() > 0 && + // recordType(c.RawInput.Bytes()[0]) == recordTypeAlert { + c.rawConn.RawInput.Bytes()[0] == 21 { + _ = c.rawConn.ReadRecord() + // return n, err // will be io.EOF on closeNotify + } + + c.readWaitOptions.PostReturn(buffer) + return +} + +func (c *ReadWaitConn) Upstream() any { + return c.Conn +} + +func (c *ReadWaitConn) ReaderReplaceable() bool { + return true +} diff --git a/common/badtls/read_wait_stub.go b/common/badtls/read_wait_stub.go new file mode 100644 index 00000000..9258a46e --- /dev/null +++ b/common/badtls/read_wait_stub.go @@ -0,0 +1,13 @@ +//go:build !go1.25 || !badlinkname + +package badtls + +import ( + "os" + + "github.com/sagernet/sing/common/tls" +) + +func NewReadWaitConn(conn tls.Conn) (tls.Conn, error) { + return nil, os.ErrInvalid +} diff --git a/common/badtls/registry.go b/common/badtls/registry.go new file mode 100644 index 00000000..34cfe9ec --- /dev/null +++ b/common/badtls/registry.go @@ -0,0 +1,62 @@ +//go:build go1.25 && badlinkname + +package badtls + +import ( + "crypto/tls" + "net" + "unsafe" +) + +type Methods struct { + readRecord func(c unsafe.Pointer) error + handlePostHandshakeMessage func(c unsafe.Pointer) error + writeRecordLocked func(c unsafe.Pointer, typ uint16, data []byte) (int, error) + + setErrorLocked func(hc unsafe.Pointer, err error) error + decrypt func(hc unsafe.Pointer, record []byte) ([]byte, uint8, error) + setTrafficSecret func(hc unsafe.Pointer, suite unsafe.Pointer, level int, secret []byte) + explicitNonceLen func(hc unsafe.Pointer) int +} + +var methodRegistry []func(conn net.Conn) (unsafe.Pointer, *Methods, bool) + +func init() { + methodRegistry = append(methodRegistry, func(conn net.Conn) (unsafe.Pointer, *Methods, bool) { + tlsConn, loaded := conn.(*tls.Conn) + if !loaded { + return nil, nil, false + } + return unsafe.Pointer(tlsConn), &Methods{ + readRecord: stdTLSReadRecord, + handlePostHandshakeMessage: stdTLSHandlePostHandshakeMessage, + writeRecordLocked: stdWriteRecordLocked, + + setErrorLocked: stdSetErrorLocked, + decrypt: stdDecrypt, + setTrafficSecret: stdSetTrafficSecret, + explicitNonceLen: stdExplicitNonceLen, + }, true + }) +} + +//go:linkname stdTLSReadRecord crypto/tls.(*Conn).readRecord +func stdTLSReadRecord(c unsafe.Pointer) error + +//go:linkname stdTLSHandlePostHandshakeMessage crypto/tls.(*Conn).handlePostHandshakeMessage +func stdTLSHandlePostHandshakeMessage(c unsafe.Pointer) error + +//go:linkname stdWriteRecordLocked crypto/tls.(*Conn).writeRecordLocked +func stdWriteRecordLocked(c unsafe.Pointer, typ uint16, data []byte) (int, error) + +//go:linkname stdSetErrorLocked crypto/tls.(*halfConn).setErrorLocked +func stdSetErrorLocked(hc unsafe.Pointer, err error) error + +//go:linkname stdDecrypt crypto/tls.(*halfConn).decrypt +func stdDecrypt(hc unsafe.Pointer, record []byte) ([]byte, uint8, error) + +//go:linkname stdSetTrafficSecret crypto/tls.(*halfConn).setTrafficSecret +func stdSetTrafficSecret(hc unsafe.Pointer, suite unsafe.Pointer, level int, secret []byte) + +//go:linkname stdExplicitNonceLen crypto/tls.(*halfConn).explicitNonceLen +func stdExplicitNonceLen(hc unsafe.Pointer) int diff --git a/common/badtls/registry_utls.go b/common/badtls/registry_utls.go new file mode 100644 index 00000000..330f64f5 --- /dev/null +++ b/common/badtls/registry_utls.go @@ -0,0 +1,56 @@ +//go:build go1.25 && badlinkname + +package badtls + +import ( + "net" + "unsafe" + + N "github.com/sagernet/sing/common/network" + + "github.com/metacubex/utls" +) + +func init() { + methodRegistry = append(methodRegistry, func(conn net.Conn) (unsafe.Pointer, *Methods, bool) { + var pointer unsafe.Pointer + if uConn, loaded := N.CastReader[*tls.Conn](conn); loaded { + pointer = unsafe.Pointer(uConn) + } else if uConn, loaded := N.CastReader[*tls.UConn](conn); loaded { + pointer = unsafe.Pointer(uConn.Conn) + } else { + return nil, nil, false + } + return pointer, &Methods{ + readRecord: utlsReadRecord, + handlePostHandshakeMessage: utlsHandlePostHandshakeMessage, + writeRecordLocked: utlsWriteRecordLocked, + + setErrorLocked: utlsSetErrorLocked, + decrypt: utlsDecrypt, + setTrafficSecret: utlsSetTrafficSecret, + explicitNonceLen: utlsExplicitNonceLen, + }, true + }) +} + +//go:linkname utlsReadRecord github.com/metacubex/utls.(*Conn).readRecord +func utlsReadRecord(c unsafe.Pointer) error + +//go:linkname utlsHandlePostHandshakeMessage github.com/metacubex/utls.(*Conn).handlePostHandshakeMessage +func utlsHandlePostHandshakeMessage(c unsafe.Pointer) error + +//go:linkname utlsWriteRecordLocked github.com/metacubex/utls.(*Conn).writeRecordLocked +func utlsWriteRecordLocked(hc unsafe.Pointer, typ uint16, data []byte) (int, error) + +//go:linkname utlsSetErrorLocked github.com/metacubex/utls.(*halfConn).setErrorLocked +func utlsSetErrorLocked(hc unsafe.Pointer, err error) error + +//go:linkname utlsDecrypt github.com/metacubex/utls.(*halfConn).decrypt +func utlsDecrypt(hc unsafe.Pointer, record []byte) ([]byte, uint8, error) + +//go:linkname utlsSetTrafficSecret github.com/metacubex/utls.(*halfConn).setTrafficSecret +func utlsSetTrafficSecret(hc unsafe.Pointer, suite unsafe.Pointer, level int, secret []byte) + +//go:linkname utlsExplicitNonceLen github.com/metacubex/utls.(*halfConn).explicitNonceLen +func utlsExplicitNonceLen(hc unsafe.Pointer) int diff --git a/common/badversion/version.go b/common/badversion/version.go new file mode 100644 index 00000000..a8404297 --- /dev/null +++ b/common/badversion/version.go @@ -0,0 +1,152 @@ +package badversion + +import ( + "strconv" + "strings" + + F "github.com/sagernet/sing/common/format" + + "golang.org/x/mod/semver" +) + +type Version struct { + Major int + Minor int + Patch int + Commit string + PreReleaseIdentifier string + PreReleaseVersion int +} + +func (v Version) LessThan(anotherVersion Version) bool { + return !v.GreaterThanOrEqual(anotherVersion) +} + +func (v Version) LessThanOrEqual(anotherVersion Version) bool { + return v == anotherVersion || anotherVersion.GreaterThan(v) +} + +func (v Version) GreaterThanOrEqual(anotherVersion Version) bool { + return v == anotherVersion || v.GreaterThan(anotherVersion) +} + +func (v Version) GreaterThan(anotherVersion Version) bool { + if v.Major > anotherVersion.Major { + return true + } else if v.Major < anotherVersion.Major { + return false + } + if v.Minor > anotherVersion.Minor { + return true + } else if v.Minor < anotherVersion.Minor { + return false + } + if v.Patch > anotherVersion.Patch { + return true + } else if v.Patch < anotherVersion.Patch { + return false + } + if v.PreReleaseIdentifier == "" && anotherVersion.PreReleaseIdentifier != "" { + return true + } else if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier == "" { + return false + } + if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier != "" { + if v.PreReleaseIdentifier == anotherVersion.PreReleaseIdentifier { + if v.PreReleaseVersion > anotherVersion.PreReleaseVersion { + return true + } else if v.PreReleaseVersion < anotherVersion.PreReleaseVersion { + return false + } + } + preReleaseIdentifier := parsePreReleaseIdentifier(v.PreReleaseIdentifier) + anotherPreReleaseIdentifier := parsePreReleaseIdentifier(anotherVersion.PreReleaseIdentifier) + if preReleaseIdentifier < anotherPreReleaseIdentifier { + return true + } else if preReleaseIdentifier > anotherPreReleaseIdentifier { + return false + } + } + return false +} + +func parsePreReleaseIdentifier(identifier string) int { + if strings.HasPrefix(identifier, "rc") { + return 1 + } else if strings.HasPrefix(identifier, "beta") { + return 2 + } else if strings.HasPrefix(identifier, "alpha") { + return 3 + } + return 0 +} + +func (v Version) VersionString() string { + return F.ToString(v.Major, ".", v.Minor, ".", v.Patch) +} + +func (v Version) String() string { + version := F.ToString(v.Major, ".", v.Minor, ".", v.Patch) + if v.PreReleaseIdentifier != "" { + version = F.ToString(version, "-", v.PreReleaseIdentifier, ".", v.PreReleaseVersion) + } + return version +} + +func (v Version) BadString() string { + version := F.ToString(v.Major, ".", v.Minor) + if v.Patch > 0 { + version = F.ToString(version, ".", v.Patch) + } + if v.PreReleaseIdentifier != "" { + version = F.ToString(version, "-", v.PreReleaseIdentifier) + if v.PreReleaseVersion > 0 { + version = F.ToString(version, v.PreReleaseVersion) + } + } + return version +} + +func IsValid(versionName string) bool { + return semver.IsValid("v" + versionName) +} + +func Parse(versionName string) (version Version) { + if strings.HasPrefix(versionName, "v") { + versionName = versionName[1:] + } + if strings.Contains(versionName, "-") { + parts := strings.Split(versionName, "-") + versionName = parts[0] + identifier := parts[1] + if strings.Contains(identifier, ".") { + identifierParts := strings.Split(identifier, ".") + version.PreReleaseIdentifier = identifierParts[0] + if len(identifierParts) >= 2 { + version.PreReleaseVersion, _ = strconv.Atoi(identifierParts[1]) + } + } else { + if strings.HasPrefix(identifier, "alpha") { + version.PreReleaseIdentifier = "alpha" + version.PreReleaseVersion, _ = strconv.Atoi(identifier[5:]) + } else if strings.HasPrefix(identifier, "beta") { + version.PreReleaseIdentifier = "beta" + version.PreReleaseVersion, _ = strconv.Atoi(identifier[4:]) + } else { + version.Commit = identifier + } + } + } + versionElements := strings.Split(versionName, ".") + versionLen := len(versionElements) + if versionLen >= 1 { + version.Major, _ = strconv.Atoi(versionElements[0]) + } + if versionLen >= 2 { + version.Minor, _ = strconv.Atoi(versionElements[1]) + } + if versionLen >= 3 { + version.Patch, _ = strconv.Atoi(versionElements[2]) + } + return +} diff --git a/common/badversion/version_json.go b/common/badversion/version_json.go new file mode 100644 index 00000000..7ec19663 --- /dev/null +++ b/common/badversion/version_json.go @@ -0,0 +1,17 @@ +package badversion + +import "github.com/sagernet/sing/common/json" + +func (v Version) MarshalJSON() ([]byte, error) { + return json.Marshal(v.String()) +} + +func (v *Version) UnmarshalJSON(data []byte) error { + var version string + err := json.Unmarshal(data, &version) + if err != nil { + return err + } + *v = Parse(version) + return nil +} diff --git a/common/badversion/version_test.go b/common/badversion/version_test.go new file mode 100644 index 00000000..d6d5a73c --- /dev/null +++ b/common/badversion/version_test.go @@ -0,0 +1,18 @@ +package badversion + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompareVersion(t *testing.T) { + t.Parallel() + require.Equal(t, "1.3.0-beta.1", Parse("v1.3.0-beta1").String()) + require.Equal(t, "1.3-beta1", Parse("v1.3.0-beta.1").BadString()) + require.True(t, Parse("1.3.0").GreaterThan(Parse("1.3-beta1"))) + require.True(t, Parse("1.3.0").GreaterThan(Parse("1.3.0-beta1"))) + require.True(t, Parse("1.3.0-beta1").GreaterThan(Parse("1.3.0-alpha1"))) + require.True(t, Parse("1.3.1").GreaterThan(Parse("1.3.0"))) + require.True(t, Parse("1.4").GreaterThan(Parse("1.3"))) +} diff --git a/common/certificate/chrome.go b/common/certificate/chrome.go new file mode 100644 index 00000000..8a361c61 --- /dev/null +++ b/common/certificate/chrome.go @@ -0,0 +1,2817 @@ +// Code generated by 'make update_certificates'. DO NOT EDIT. + +package certificate + +import "crypto/x509" + +var chromeIncluded *x509.CertPool + +func init() { + chromeIncluded = x509.NewCertPool() + + // CN=Actalis Authentication Root CA; O=Actalis S.p.A./03358520967; L=Milan; C=IT + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UE +BhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8w +MzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 +IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDkyMjExMjIwMlowazELMAkGA1UEBhMC +SVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1 +ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENB +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNv +UTufClrJwkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX +4ay8IMKx4INRimlNAJZaby/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9 +KK3giq0itFZljoZUj5NDKd45RnijMCO6zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/ +gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1fYVEiVRvjRuPjPdA1Yprb +rxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2oxgkg4YQ +51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2F +be8lEfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxe +KF+w6D9Fz8+vm2/7hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4F +v6MGn8i1zeQf1xcGDXqVdFUNaBr8EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbn +fpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5jF66CyCU3nuDuP/jVo23Eek7 +jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLYiDrIn3hm7Ynz +ezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt +ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQAL +e3KHwGCmSUyIWOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70 +jsNjLiNmsGe+b7bAEzlgqqI0JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDz +WochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKxK3JCaKygvU5a2hi/a5iB0P2avl4V +SM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+Xlff1ANATIGk0k9j +pwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC4yyX +X04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+Ok +fcvHlXHo2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7R +K4X9p2jIugErsWx0Hbhzlefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btU +ZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXemOR/qnuOf0GZvBeyqdn6/axag67XH/JJU +LysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9vwGYT7JZVEc+NHt4bVaT +LnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== +-----END CERTIFICATE-----`)) + + // CN=TunTrust Root CA; O=Agence Nationale de Certification Electronique; C=TN + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIUEwLV4kBMkkaGFmddtLu7sms+/BMwDQYJKoZIhvcNAQEL +BQAwYTELMAkGA1UEBhMCVE4xNzA1BgNVBAoMLkFnZW5jZSBOYXRpb25hbGUgZGUg +Q2VydGlmaWNhdGlvbiBFbGVjdHJvbmlxdWUxGTAXBgNVBAMMEFR1blRydXN0IFJv +b3QgQ0EwHhcNMTkwNDI2MDg1NzU2WhcNNDQwNDI2MDg1NzU2WjBhMQswCQYDVQQG +EwJUTjE3MDUGA1UECgwuQWdlbmNlIE5hdGlvbmFsZSBkZSBDZXJ0aWZpY2F0aW9u +IEVsZWN0cm9uaXF1ZTEZMBcGA1UEAwwQVHVuVHJ1c3QgUm9vdCBDQTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAMPN0/y9BFPdDCA61YguBUtB9YOCfvdZ +n56eY+hz2vYGqU8ftPkLHzmMmiDQfgbU7DTZhrx1W4eI8NLZ1KMKsmwb60ksPqxd +2JQDoOw05TDENX37Jk0bbjBU2PWARZw5rZzJJQRNmpA+TkBuimvNKWfGzC3gdOgF +VwpIUPp6Q9p+7FuaDmJ2/uqdHYVy7BG7NegfJ7/Boce7SBbdVtfMTqDhuazb1YMZ +GoXRlJfXyqNlC/M4+QKu3fZnz8k/9YosRxqZbwUN/dAdgjH8KcwAWJeRTIAAHDOF +li/LQcKLEITDCSSJH7UP2dl3RxiSlGBcx5kDPP73lad9UKGAwqmDrViWVSHbhlnU +r8a83YFuB9tgYv7sEG7aaAH0gxupPqJbI9dkxt/con3YS7qC0lH4Zr8GRuR5KiY2 +eY8fTpkdso8MDhz/yV3A/ZAQprE38806JG60hZC/gLkMjNWb1sjxVj8agIl6qeIb +MlEsPvLfe/ZdeikZjuXIvTZxi11Mwh0/rViizz1wTaZQmCXcI/m4WEEIcb9PuISg +jwBUFfyRbVinljvrS5YnzWuioYasDXxU5mZMZl+QviGaAkYt5IPCgLnPSz7ofzwB +7I9ezX/SKEIBlYrilz0QIX32nRzFNKHsLA4KUiwSVXAkPcvCFDVDXSdOvsC9qnyW +5/yeYa1E0wCXAgMBAAGjYzBhMB0GA1UdDgQWBBQGmpsfU33x9aTI04Y+oXNZtPdE +ITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFAaamx9TffH1pMjThj6hc1m0 +90QhMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAqgVutt0Vyb+z +xiD2BkewhpMl0425yAA/l/VSJ4hxyXT968pk21vvHl26v9Hr7lxpuhbI87mP0zYu +QEkHDVneixCwSQXi/5E/S7fdAo74gShczNxtr18UnH1YeA32gAm56Q6XKRm4t+v4 +FstVEuTGfbvE7Pi1HE4+Z7/FXxttbUcoqgRYYdZ2vyJ/0Adqp2RT8JeNnYA/u8EH +22Wv5psymsNUk8QcCMNE+3tjEUPRahphanltkE8pjkcFwRJpadbGNjHh/PqAulxP +xOu3Mqz4dWEX1xAZufHSCe96Qp1bWgvUxpVOKs7/B9dPfhgGiPEZtdmYu65xxBzn +dFlY7wyJz4sfdZMaBBSSSFCp61cpABbjNhzI+L/wM9VBD8TMPN3pM0MBkRArHtG5 +Xc0yGYuPjCB31yLEQtyEFpslbei0VXF/sHyz03FJuc9SpAQ/3D2gu68zngowYI7b +nV2UqL1g52KAdoGDDIzMMEZJ4gzSqK/rYXHv5yJiqfdcZGyfFoxnNidF9Ql7v/YQ +CvGwjVRDjAS6oz/v4jXH+XTgbzRB0L9zZVcg+ZtnemZoJE6AZb0QmQZZ8mWvuMZH +u/2QeItBcy6vVR/cO5JyboTT0GFMDcx2V+IthSIVNg3rAZ3r2OvEhJn7wAzMMujj +d9qDRIueVSjAi1jTkD5OGwDxFa2DK5o= +-----END CERTIFICATE-----`)) + + // CN=Amazon Root CA 4; O=Amazon; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g +Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG +A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg +Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi +9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk +M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB +MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw +CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW +1KyLa2tJElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE-----`)) + + // CN=Amazon Root CA 1; O=Amazon; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA +A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI +U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs +N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv +o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU +5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy +rqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE-----`)) + + // CN=Amazon Root CA 2; O=Amazon; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK +gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ +W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg +1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K +8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r +2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me +z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR +8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj +mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz +7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 ++XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI +0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm +UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 +LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY ++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS +k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl +7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm +btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl +urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ +fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 +n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE +76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H +9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT +4PsJYGw= +-----END CERTIFICATE-----`)) + + // CN=Amazon Root CA 3; O=Amazon; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g +Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG +A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg +Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl +ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr +ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr +BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM +YyRIHN8wfdVoOw== +-----END CERTIFICATE-----`)) + + // CN=Certum Trusted Network CA; OU=Certum Certification Authority; O=Unizeto Technologies S.A.; C=PL + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBM +MSIwIAYDVQQKExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5D +ZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBU +cnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIyMTIwNzM3WhcNMjkxMjMxMTIwNzM3 +WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMg +Uy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSIw +IAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rH +UV+rpDKmYYe2bg+G0jACl/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LM +TXPb865Px1bVWqeWifrzq2jUI4ZZJ88JJ7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVU +BBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4fOQtf/WsX+sWn7Et0brM +kUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0cvW0QM8x +AcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNV +HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15y +sHhE49wcrwn9I0j6vSrEuVUEtRCjjSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfL +I9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1mS1FhIrlQgnXdAIv94nYmem8 +J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5ajZt3hrvJBW8qY +VoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI +03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= +-----END CERTIFICATE-----`)) + + // CN=Certum EC-384 CA; OU=Certum Certification Authority; O=Asseco Data Systems S.A.; C=PL + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICZTCCAeugAwIBAgIQeI8nXIESUiClBNAt3bpz9DAKBggqhkjOPQQDAzB0MQsw +CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScw +JQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGTAXBgNVBAMT +EENlcnR1bSBFQy0zODQgQ0EwHhcNMTgwMzI2MDcyNDU0WhcNNDMwMzI2MDcyNDU0 +WjB0MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBT +LkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGTAX +BgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATE +KI6rGFtqvm5kN2PkzeyrOvfMobgOgknXhimfoZTy42B4mIF4Bk3y7JoOV2CDn7Tm +Fy8as10CW4kjPMIRBSqniBMY81CE1700LCeJVf/OTOffph8oxPBUw7l8t1Ot68Kj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI0GZnQkdjrzife81r1HfS+8 +EF9LMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjADVS2m5hjEfO/J +UG7BJw+ch69u1RsIGL2SKcHvlJF40jocVYli5RsJHrpka/F2tNQCMQC0QoSZ/6vn +nvuRlydd3LBbMHHOXjgaatkl5+r3YZJW+OraNsKHZZYuciUvf9/DE8k= +-----END CERTIFICATE-----`)) + + // CN=Certum Trusted Root CA; OU=Certum Certification Authority; O=Asseco Data Systems S.A.; C=PL + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFwDCCA6igAwIBAgIQHr9ZULjJgDdMBvfrVU+17TANBgkqhkiG9w0BAQ0FADB6 +MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu +MScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxHzAdBgNV +BAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwHhcNMTgwMzE2MTIxMDEzWhcNNDMw +MzE2MTIxMDEzWjB6MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEg +U3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRo +b3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQDRLY67tzbqbTeRn06TpwXkKQMlzhyC93yZ +n0EGze2jusDbCSzBfN8pfktlL5On1AFrAygYo9idBcEq2EXxkd7fO9CAAozPOA/q +p1x4EaTByIVcJdPTsuclzxFUl6s1wB52HO8AU5853BSlLCIls3Jy/I2z5T4IHhQq +NwuIPMqw9MjCoa68wb4pZ1Xi/K1ZXP69VyywkI3C7Te2fJmItdUDmj0VDT06qKhF +8JVOJVkdzZhpu9PMMsmN74H+rX2Ju7pgE8pllWeg8xn2A1bUatMn4qGtg/BKEiJ3 +HAVz4hlxQsDsdUaakFjgao4rpUYwBI4Zshfjvqm6f1bxJAPXsiEodg42MEx51UGa +mqi4NboMOvJEGyCI98Ul1z3G4z5D3Yf+xOr1Uz5MZf87Sst4WmsXXw3Hw09Omiqi +7VdNIuJGmj8PkTQkfVXjjJU30xrwCSss0smNtA0Aq2cpKNgB9RkEth2+dv5yXMSF +ytKAQd8FqKPVhJBPC/PgP5sZ0jeJP/J7UhyM9uH3PAeXjA6iWYEMspA90+NZRu0P +qafegGtaqge2Gcu8V/OXIXoMsSt0Puvap2ctTMSYnjYJdmZm/Bo/6khUHL4wvYBQ +v3y1zgD2DGHZ5yQD4OMBgQ692IU0iL2yNqh7XAjlRICMb/gv1SHKHRzQ+8S1h9E6 +Tsd2tTVItQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSM+xx1 +vALTn04uSNn5YFSqxLNP+jAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQAD +ggIBAEii1QALLtA/vBzVtVRJHlpr9OTy4EA34MwUe7nJ+jW1dReTagVphZzNTxl4 +WxmB82M+w85bj/UvXgF2Ez8sALnNllI5SW0ETsXpD4YN4fqzX4IS8TrOZgYkNCvo +zMrnadyHncI013nR03e4qllY/p0m+jiGPp2Kh2RX5Rc64vmNueMzeMGQ2Ljdt4NR +5MTMI9UGfOZR0800McD2RrsLrfw9EAUqO0qRJe6M1ISHgCq8CYyqOhNf6DR5UMEQ +GfnTKB7U0VEwKbOukGfWHwpjscWpxkIxYxeU72nLL/qMFH3EQxiJ2fAyQOaA4kZf +5ePBAFmo+eggvIksDkc0C+pXwlM2/KfUrzHN/gLldfq5Jwn58/U7yn2fqSLLiMmq +0Uc9NneoWWRrJ8/vJ8HjJLWG965+Mk2weWjROeiQWMODvA8s1pfrzgzhIMfatz7D +P78v3DSk+yshzWePS/Tj6tQ/50+6uaWTRRxmHyH6ZF5v4HaUMst19W7l9o/HuKTM +qJZ9ZPskWkoDbGs4xugDQ5r3V7mzKWmTOPQD8rv7gmsHINFSH5pkAnuYZttcTVoP +0ISVoDwUQwbKytu4QTbaakRnh6+v40URFWkIsr4WOZckbxJF0WddCajJFdr60qZf +E2Efv4WstK2tBZQIgx51F9NxO5NQI1mg7TyRVJ12AMXDuDjb +-----END CERTIFICATE-----`)) + + // CN=Certum Trusted Network CA 2; OU=Certum Certification Authority; O=Unizeto Technologies S.A.; C=PL + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCB +gDELMAkGA1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMu +QS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIG +A1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMCIYDzIwMTExMDA2MDgz +OTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UEChMZ +VW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3 +b3JrIENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWA +DGSdhhuWZGc/IjoedQF97/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn +0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+oCgCXhVqqndwpyeI1B+twTUrWwbNWuKFB +OJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40bRr5HMNUuctHFY9rnY3lE +fktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2puTRZCr+E +Sv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1m +o130GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02i +sx7QBlrd9pPPV3WZ9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOW +OZV7bIBaTxNyxtd9KXpEulKkKtVBRgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgez +Tv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pyehizKV/Ma5ciSixqClnrDvFAS +adgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vMBhBgu4M1t15n +3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQ +F/xlhMcQSZDe28cmk4gmb3DWAl45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTf +CVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuAL55MYIR4PSFk1vtBHxgP58l1cb29 +XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMoclm2q8KMZiYcdywm +djWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tMpkT/ +WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jb +AoJnwTnbw3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksq +P/ujmv5zMnHCnsZy4YpoJ/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Ko +b7a6bINDd82Kkhehnlt4Fj1F4jNy3eFmypnTycUm/Q1oBEauttmbjL4ZvrHG8hnj +XALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLXis7VmFxWlgPF7ncGNf/P +5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7zAYspsbi +DrW5viSP +-----END CERTIFICATE-----`)) + + // CN=Autoridad de Certificacion Firmaprofesional CIF A62634068; C=ES + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGFDCCA/ygAwIBAgIIG3Dp0v+ubHEwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UE +BhMCRVMxQjBABgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1h +cHJvZmVzaW9uYWwgQ0lGIEE2MjYzNDA2ODAeFw0xNDA5MjMxNTIyMDdaFw0zNjA1 +MDUxNTIyMDdaMFExCzAJBgNVBAYTAkVTMUIwQAYDVQQDDDlBdXRvcmlkYWQgZGUg +Q2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBBNjI2MzQwNjgwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDDUtd9 +thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQM +cas9UX4PB99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefG +L9ItWY16Ck6WaVICqjaY7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15i +NA9wBj4gGFrO93IbJWyTdBSTo3OxDqqHECNZXyAFGUftaI6SEspd/NYrspI8IM/h +X68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyIplD9amML9ZMWGxmPsu2b +m8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctXMbScyJCy +Z/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirja +EbsXLZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/T +KI8xWVvTyQKmtFLKbpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF +6NkBiDkal4ZkQdU7hwxu+g/GvUgUvzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVh +OSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMB0GA1UdDgQWBBRlzeurNR4APn7VdMAc +tHNHDhpkLzASBgNVHRMBAf8ECDAGAQH/AgEBMIGmBgNVHSAEgZ4wgZswgZgGBFUd +IAAwgY8wLwYIKwYBBQUHAgEWI2h0dHA6Ly93d3cuZmlybWFwcm9mZXNpb25hbC5j +b20vY3BzMFwGCCsGAQUFBwICMFAeTgBQAGEAcwBlAG8AIABkAGUAIABsAGEAIABC +AG8AbgBhAG4AbwB2AGEAIAA0ADcAIABCAGEAcgBjAGUAbABvAG4AYQAgADAAOAAw +ADEANzAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAHSHKAIrdx9m +iWTtj3QuRhy7qPj4Cx2Dtjqn6EWKB7fgPiDL4QjbEwj4KKE1soCzC1HA01aajTNF +Sa9J8OA9B3pFE1r/yJfY0xgsfZb43aJlQ3CTkBW6kN/oGbDbLIpgD7dvlAceHabJ +hfa9NPhAeGIQcDq+fUs5gakQ1JZBu/hfHAsdCPKxsIl68veg4MSPi3i1O1ilI45P +Vf42O+AMt8oqMEEgtIDNrvx2ZnOorm7hfNoD6JQg5iKj0B+QXSBTFCZX2lSX3xZE +EAEeiGaPcjiT3SC3NL7X8e5jjkd5KAb881lFJWAiMxujX6i6KtoaPc1A6ozuBRWV +1aUsIC+nmCjuRfzxuIgALI9C2lHVnOUTaHFFQ4ueCyE8S1wF3BqfmI7avSKecs2t +CsvMo2ebKHTEm9caPARYpoKdrcd7b/+Alun4jWq9GJAd/0kakFI3ky88Al2CdgtR +5xbHV/g4+afNmyJU72OwFW1TZQNKXkqgsqeOSQBZONXH9IBk9W6VULgRfhVwOEqw +f9DEMnDAGf/JOC0ULGb0QkTmVXYbgBVX/8Cnp6o5qtjTcNAuuuuUavpfNIbnYrX9 +ivAwhZTJryQCL2/W3Wf+47BVTwSYT6RBVuKT0Gro1vP7ZeDOdcQxWQzugsgMYDNK +GbqEZycPvEJdvSRUDewdcAZfpLz6IHxV +-----END CERTIFICATE-----`)) + + // CN=ANF Secure Server Root CA; OU=ANF CA Raiz; O=ANF Autoridad de Certificacion; C=ES; SerialNumber=G63287510 + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF7zCCA9egAwIBAgIIDdPjvGz5a7EwDQYJKoZIhvcNAQELBQAwgYQxEjAQBgNV +BAUTCUc2MzI4NzUxMDELMAkGA1UEBhMCRVMxJzAlBgNVBAoTHkFORiBBdXRvcmlk +YWQgZGUgQ2VydGlmaWNhY2lvbjEUMBIGA1UECxMLQU5GIENBIFJhaXoxIjAgBgNV +BAMTGUFORiBTZWN1cmUgU2VydmVyIFJvb3QgQ0EwHhcNMTkwOTA0MTAwMDM4WhcN +MzkwODMwMTAwMDM4WjCBhDESMBAGA1UEBRMJRzYzMjg3NTEwMQswCQYDVQQGEwJF +UzEnMCUGA1UEChMeQU5GIEF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uMRQwEgYD +VQQLEwtBTkYgQ0EgUmFpejEiMCAGA1UEAxMZQU5GIFNlY3VyZSBTZXJ2ZXIgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANvrayvmZFSVgpCj +cqQZAZ2cC4Ffc0m6p6zzBE57lgvsEeBbphzOG9INgxwruJ4dfkUyYA8H6XdYfp9q +yGFOtibBTI3/TO80sh9l2Ll49a2pcbnvT1gdpd50IJeh7WhM3pIXS7yr/2WanvtH +2Vdy8wmhrnZEE26cLUQ5vPnHO6RYPUG9tMJJo8gN0pcvB2VSAKduyK9o7PQUlrZX +H1bDOZ8rbeTzPvY1ZNoMHKGESy9LS+IsJJ1tk0DrtSOOMspvRdOoiXsezx76W0OL +zc2oD2rKDF65nkeP8Nm2CgtYZRczuSPkdxl9y0oukntPLxB3sY0vaJxizOBQ+OyR +p1RMVwnVdmPF6GUe7m1qzwmd+nxPrWAI/VaZDxUse6mAq4xhj0oHdkLePfTdsiQz +W7i1o0TJrH93PB0j7IKppuLIBkwC/qxcmZkLLxCKpvR/1Yd0DVlJRfbwcVw5Kda/ +SiOL9V8BY9KHcyi1Swr1+KuCLH5zJTIdC2MKF4EA/7Z2Xue0sUDKIbvVgFHlSFJn +LNJhiQcND85Cd8BEc5xEUKDbEAotlRyBr+Qc5RQe8TZBAQIvfXOn3kLMTOmJDVb3 +n5HUA8ZsyY/b2BzgQJhdZpmYgG4t/wHFzstGH6wCxkPmrqKEPMVOHj1tyRRM4y5B +u8o5vzY8KhmqQYdOpc5LMnndkEl/AgMBAAGjYzBhMB8GA1UdIwQYMBaAFJxf0Gxj +o1+TypOYCK2Mh6UsXME3MB0GA1UdDgQWBBScX9BsY6Nfk8qTmAitjIelLFzBNzAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AgEATh65isagmD9uw2nAalxJUqzLK114OMHVVISfk/CHGT0sZonrDUL8zPB1hT+L +9IBdeeUXZ701guLyPI59WzbLWoAAKfLOKyzxj6ptBZNscsdW699QIyjlRRA96Gej +rw5VD5AJYu9LWaL2U/HANeQvwSS9eS9OICI7/RogsKQOLHDtdD+4E5UGUcjohybK +pFtqFiGS3XNgnhAY3jyB6ugYw3yJ8otQPr0R4hUDqDZ9MwFsSBXXiJCZBMXM5gf0 +vPSQ7RPi6ovDj6MzD8EpTBNO2hVWcXNyglD2mjN8orGoGjR0ZVzO0eurU+AagNjq +OknkJjCb5RyKqKkVMoaZkgoQI1YS4PbOTOK7vtuNknMBZi9iPrJyJ0U27U1W45eZ +/zo1PqVUSlJZS2Db7v54EX9K3BR5YLZrZAPbFYPhor72I5dQ8AkzNqdxliXzuUJ9 +2zg/LFis6ELhDtjTO0wugumDLmsx2d1Hhk9tl5EuT+IocTUW0fJz/iUrB0ckYyfI ++PbZa/wSMVYIwFNCr5zQM378BvAxRAMU8Vjq8moNqRGyg77FGr8H6lnco4g175x2 +MjxNBiLOFeXdntiP2t7SxDnlF4HPOEfrf4htWRvfn0IUrn7PqLBmZdo3r5+qPeoo +tt7VMVgWglvquxl1AnMaykgaIZOQCo6ThKd9OyMYkomgjaw= +-----END CERTIFICATE-----`)) + + // CN=Buypass Class 2 Root CA; O=Buypass AS-983163327; C=NO + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEd +MBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3Mg +Q2xhc3MgMiBSb290IENBMB4XDTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1ow +TjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAw +HgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1g1Lr +6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPV +L4O2fuPn9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC91 +1K2GScuVr1QGbNgGE41b/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHx +MlAQTn/0hpPshNOOvEu/XAFOBz3cFIqUCqTqc/sLUegTBxj6DvEr0VQVfTzh97QZ +QmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeffawrbD02TTqigzXsu8lkB +arcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgIzRFo1clr +Us3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLi +FRhnBkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRS +P/TizPJhk9H9Z2vXUq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN +9SG9dKpN6nIDSdvHXx1iY8f93ZHsM+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxP +AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMmAd+BikoL1Rpzz +uvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAU18h +9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s +A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3t +OluwlN5E40EIosHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo ++fsicdl9sz1Gv7SEr5AcD48Saq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7 +KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYdDnkM/crqJIByw5c/8nerQyIKx+u2 +DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWDLfJ6v9r9jv6ly0Us +H8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0oyLQ +I+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK7 +5t98biGCwWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h +3PFaTWwyI0PurKju7koSCTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPz +Y11aWOIv4x3kqdbQCtCev9eBCfHJxyYNrJgWVqA= +-----END CERTIFICATE-----`)) + + // CN=Buypass Class 3 Root CA; O=Buypass AS-983163327; C=NO + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEd +MBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3Mg +Q2xhc3MgMyBSb290IENBMB4XDTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFow +TjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAw +HgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRHsJ8Y +ZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3E +N3coTRiR5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9 +tznDDgFHmV0ST9tD+leh7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX +0DJq1l1sDPGzbjniazEuOQAnFN44wOwZZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c +/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH2xc519woe2v1n/MuwU8X +KhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV/afmiSTY +zIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvS +O1UQRwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D +34xFMFbG02SrZvPAXpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgP +K9Dx2hzLabjKSWJtyNBjYt1gD1iqj6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3 +AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEe4zf/lb+74suwv +Tg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAACAj +QTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV +cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXS +IGrs/CIBKM+GuIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2 +HJLw5QY33KbmkJs4j1xrG0aGQ0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsa +O5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8ZORK15FTAaggiG6cX0S5y2CBNOxv +033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2KSb12tjE8nVhz36u +dmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz6MkE +kbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg41 +3OEMXbugUZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvD +u79leNKGef9JOxqDDPDeeOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq +4/g7u9xN12TyUb7mqqta6THuBrxzvxNiCp/HuZc= +-----END CERTIFICATE-----`)) + + // CN=Certainly Root R1; O=Certainly; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIRAI4P+UuQcWhlM1T01EQ5t+AwDQYJKoZIhvcNAQELBQAw +PTELMAkGA1UEBhMCVVMxEjAQBgNVBAoTCUNlcnRhaW5seTEaMBgGA1UEAxMRQ2Vy +dGFpbmx5IFJvb3QgUjEwHhcNMjEwNDAxMDAwMDAwWhcNNDYwNDAxMDAwMDAwWjA9 +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0 +YWlubHkgUm9vdCBSMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANA2 +1B/q3avk0bbm+yLA3RMNansiExyXPGhjZjKcA7WNpIGD2ngwEc/csiu+kr+O5MQT +vqRoTNoCaBZ0vrLdBORrKt03H2As2/X3oXyVtwxwhi7xOu9S98zTm/mLvg7fMbed +aFySpvXl8wo0tf97ouSHocavFwDvA5HtqRxOcT3Si2yJ9HiG5mpJoM610rCrm/b0 +1C7jcvk2xusVtyWMOvwlDbMicyF0yEqWYZL1LwsYpfSt4u5BvQF5+paMjRcCMLT5 +r3gajLQ2EBAHBXDQ9DGQilHFhiZ5shGIXsXwClTNSaa/ApzSRKft43jvRl5tcdF5 +cBxGX1HpyTfcX35pe0HfNEXgO4T0oYoKNp43zGJS4YkNKPl6I7ENPT2a/Z2B7yyQ +wHtETrtJ4A5KVpK8y7XdeReJkd5hiXSSqOMyhb5OhaRLWcsrxXiOcVTQAjeZjOVJ +6uBUcqQRBi8LjMFbvrWhsFNunLhgkR9Za/kt9JQKl7XsxXYDVBtlUrpMklZRNaBA +2CnbrlJ2Oy0wQJuK0EJWtLeIAaSHO1OWzaMWj/Nmqhexx2DgwUMFDO6bW2BvBlyH +Wyf5QBGenDPBt+U1VwV/J84XIIwc/PH72jEpSe31C4SnT8H2TsIonPru4K8H+zMR +eiFPCyEQtkA6qyI6BJyLm4SGcprSp6XEtHWRqSsjAgMBAAGjQjBAMA4GA1UdDwEB +/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTgqj8ljZ9EXME66C6u +d0yEPmcM9DANBgkqhkiG9w0BAQsFAAOCAgEAuVevuBLaV4OPaAszHQNTVfSVcOQr +PbA56/qJYv331hgELyE03fFo8NWWWt7CgKPBjcZq91l3rhVkz1t5BXdm6ozTaw3d +8VkswTOlMIAVRQdFGjEitpIAq5lNOo93r6kiyi9jyhXWx8bwPWz8HA2YEGGeEaIi +1wrykXprOQ4vMMM2SZ/g6Q8CRFA3lFV96p/2O7qUpUzpvD5RtOjKkjZUbVwlKNrd +rRT90+7iIgXr0PK3aBLXWopBGsaSpVo7Y0VPv+E6dyIvXL9G+VoDhRNCX8reU9di +taY1BMJH/5n9hN9czulegChB8n3nHpDYT3Y+gjwN/KUD+nsa2UUeYNrEjvn8K8l7 +lcUq/6qJ34IxD3L/DCfXCh5WAFAeDJDBlrXYFIW7pw0WwfgHJBu6haEaBQmAupVj +yTrsJZ9/nbqkRxWbRHDxakvWOF5D8xh+UG7pWijmZeZ3Gzr9Hb4DJqPb1OG7fpYn +Kx3upPvaJVQTA945xsMfTZDsjxtK0hzthZU4UHlG1sGQUDGpXJpuHfUzVounmdLy +yCwzk5Iwx06MZTMQZBf9JBeW0Y3COmor6xOLRPIh80oat3df1+2IpHLlOR+Vnb5n +wXARPbv0+Em34yaXOp/SX3z7wJl8OSngex2/DaeP0ik0biQVy96QXr8axGbqwua6 +OV+KmalBWQewLK8= +-----END CERTIFICATE-----`)) + + // CN=Certainly Root E1; O=Certainly; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB9zCCAX2gAwIBAgIQBiUzsUcDMydc+Y2aub/M+DAKBggqhkjOPQQDAzA9MQsw +CQYDVQQGEwJVUzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0YWlu +bHkgUm9vdCBFMTAeFw0yMTA0MDEwMDAwMDBaFw00NjA0MDEwMDAwMDBaMD0xCzAJ +BgNVBAYTAlVTMRIwEAYDVQQKEwlDZXJ0YWlubHkxGjAYBgNVBAMTEUNlcnRhaW5s +eSBSb290IEUxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE3m/4fxzf7flHh4axpMCK ++IKXgOqPyEpeKn2IaKcBYhSRJHpcnqMXfYqGITQYUBsQ3tA3SybHGWCA6TS9YBk2 +QNYphwk8kXr2vBMj3VlOBF7PyAIcGFPBMdjaIOlEjeR2o0IwQDAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ygYy2R17ikq6+2uI1g4 +hevIIgcwCgYIKoZIzj0EAwMDaAAwZQIxALGOWiDDshliTd6wT99u0nCK8Z9+aozm +ut6Dacpps6kFtZaSF4fC0urQe87YQVt8rgIwRt7qy12a7DLCZRawTDBcMPPaTnOG +BtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR +-----END CERTIFICATE-----`)) + + // CN=Certigna; O=Dhimyotis; C=FR + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNV +BAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4X +DTA3MDYyOTE1MTMwNVoXDTI3MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQ +BgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwIQ2VydGlnbmEwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7qXOEm7RFHYeGifBZ4 +QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyHGxny +gQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbw +zBfsV1/pogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q +130yGLMLLGq/jj8UEYkgDncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2 +JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKfIrjxwo1p3Po6WAbfAgMBAAGjgbwwgbkw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQtCRZvgHyUtVF9lo53BEw +ZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJBgNVBAYT +AkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzj +AQ/JSP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG +9w0BAQUFAAOCAQEAhQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8h +bV6lUmPOEvjvKtpv6zf+EwLHyzs+ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFnc +fca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1kluPBS1xp81HlDQwY9qcEQCYsuu +HWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY1gkIl2PlwS6w +t0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw +WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== +-----END CERTIFICATE-----`)) + + // CN=Certigna Root CA; OU=0002 48146308100036; O=Dhimyotis; C=FR + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAw +WjELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAw +MiA0ODE0NjMwODEwMDAzNjEZMBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0x +MzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjdaMFoxCzAJBgNVBAYTAkZSMRIwEAYD +VQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYzMDgxMDAwMzYxGTAX +BgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw +ggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sO +ty3tRQgXstmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9M +CiBtnyN6tMbaLOQdLNyzKNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPu +I9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8JXrJhFwLrN1CTivngqIkicuQstDuI7pm +TLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16XdG+RCYyKfHx9WzMfgIh +C59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq4NYKpkDf +ePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3Yz +IoejwpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWT +Co/1VTp2lc5ZmIoJlXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1k +JWumIWmbat10TWuXekG9qxf5kBdIjzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5 +hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp//TBt2dzhauH8XwIDAQABo4IB +GjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of +1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczov +L3d3d3cuY2VydGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilo +dHRwOi8vY3JsLmNlcnRpZ25hLmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYr +aHR0cDovL2NybC5kaGlteW90aXMuY29tL2NlcnRpZ25hcm9vdGNhLmNybDANBgkq +hkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOItOoldaDgvUSILSo3L +6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxPTGRG +HVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH6 +0BGM+RFq7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncB +lA2c5uk5jR+mUYyZDDl34bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdi +o2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1 +gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS6Cvu5zHbugRqh5jnxV/v +faci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaYtlu3zM63 +Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayh +jWZSaX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw +3kAP+HwV96LOPNdeE4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0= +-----END CERTIFICATE-----`)) + + // OU=certSIGN ROOT CA; O=certSIGN; C=RO + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYT +AlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBD +QTAeFw0wNjA3MDQxNzIwMDRaFw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJP +MREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7IJUqOtdu0KBuqV5Do +0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHHrfAQ +UySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5d +RdY4zTW2ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQ +OA7+j0xbm0bqQfWwCHTD0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwv +JoIQ4uNllAoEwF73XVv4EOLQunpL+943AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08C +AwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAcYwHQYDVR0O +BBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IBAQA+0hyJ +LjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecY +MnQ8SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ +44gx+FkagQnIl6Z0x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6I +Jd1hJyMctTEHBDa0GpC9oHRxUIltvBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNw +i/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7NzTogVZ96edhBiIL5VaZVDADlN +9u6wWk5JRFRYX0KD +-----END CERTIFICATE-----`)) + + // OU=certSIGN ROOT CA G2; O=CERTSIGN SA; C=RO + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNV +BAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04g +Uk9PVCBDQSBHMjAeFw0xNzAyMDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJ +BgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJ +R04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMDF +dRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05N0Iw +vlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZ +uIt4ImfkabBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhp +n+Sc8CnTXPnGFiWeI8MgwT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKs +cpc/I1mbySKEwQdPzH/iV8oScLumZfNpdWO9lfsbl83kqK/20U6o2YpxJM02PbyW +xPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91QqhngLjYl/rNUssuHLoPj1P +rCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732jcZZroiF +DsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fx +DTvf95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgy +LcsUDFDYg2WD7rlcz8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6C +eWRgKRM+o/1Pcmqr4tTluCRVLERLiohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB +/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSCIS1mxteg4BXrzkwJ +d8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOBywaK8SJJ6ejq +kX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC +b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQl +qiCA2ClV9+BB/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0 +OJD7uNGzcgbJceaBxXntC6Z58hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+c +NywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5BiKDUyUM/FHE5r7iOZULJK2v0ZXk +ltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklWatKcsWMy5WHgUyIO +pwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tUSxfj +03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZk +PuXaTH4MNMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE +1LlSVHJ7liXMvGnjSG4N0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MX +QRBdJ3NghVdJIgc= +-----END CERTIFICATE-----`)) + + // CN=HiPKI Root CA - G1; O=Chunghwa Telecom Co., Ltd.; C=TW + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFajCCA1KgAwIBAgIQLd2szmKXlKFD6LDNdmpeYDANBgkqhkiG9w0BAQsFADBP +MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0 +ZC4xGzAZBgNVBAMMEkhpUEtJIFJvb3QgQ0EgLSBHMTAeFw0xOTAyMjIwOTQ2MDRa +Fw0zNzEyMzExNTU5NTlaME8xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3 +YSBUZWxlY29tIENvLiwgTHRkLjEbMBkGA1UEAwwSSGlQS0kgUm9vdCBDQSAtIEcx +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9B5/UnMyDHPkvRN0o9Qw +qNCuS9i233VHZvR85zkEHmpwINJaR3JnVfSl6J3VHiGh8Ge6zCFovkRTv4354twv +Vcg3Px+kwJyz5HdcoEb+d/oaoDjq7Zpy3iu9lFc6uux55199QmQ5eiY29yTw1S+6 +lZgRZq2XNdZ1AYDgr/SEYYwNHl98h5ZeQa/rh+r4XfEuiAU+TCK72h8q3VJGZDnz +Qs7ZngyzsHeXZJzA9KMuH5UHsBffMNsAGJZMoYFL3QRtU6M9/Aes1MU3guvklQgZ +KILSQjqj2FPseYlgSGDIcpJQ3AOPgz+yQlda22rpEZfdhSi8MEyr48KxRURHH+CK +FgeW0iEPU8DtqX7UTuybCeyvQqww1r/REEXgphaypcXTT3OUM3ECoWqj1jOXTyFj +HluP2cFeRXF3D4FdXyGarYPM+l7WjSNfGz1BryB1ZlpK9p/7qxj3ccC2HTHsOyDr +y+K49a6SsvfhhEvyovKTmiKe0xRvNlS9H15ZFblzqMF8b3ti6RZsR1pl8w4Rm0bZ +/W3c1pzAtH2lsN0/Vm+h+fbkEkj9Bn8SV7apI09bA8PgcSojt/ewsTu8mL3WmKgM +a/aOEmem8rJY5AIJEzypuxC00jBF8ez3ABHfZfjcK0NVvxaXxA/VLGGEqnKG/uY6 +fsI/fe78LxQ+5oXdUG+3Se0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQU8ncX+l6o/vY9cdVouslGDDjYr7AwDgYDVR0PAQH/BAQDAgGGMA0GCSqG +SIb3DQEBCwUAA4ICAQBQUfB13HAE4/+qddRxosuej6ip0691x1TPOhwEmSKsxBHi +7zNKpiMdDg1H2DfHb680f0+BazVP6XKlMeJ45/dOlBhbQH3PayFUhuaVevvGyuqc +SE5XCV0vrPSltJczWNWseanMX/mF+lLFjfiRFOs6DRfQUsJ748JzjkZ4Bjgs6Fza +ZsT0pPBWGTMpWmWSBUdGSquEwx4noR8RkpkndZMPvDY7l1ePJlsMu5wP1G4wB9Tc +XzZoZjmDlicmisjEOf6aIW/Vcobpf2Lll07QJNBAsNB1CI69aO4I1258EHBGG3zg +iLKecoaZAeO/n0kZtCW+VmWuF2PlHt/o/0elv+EmBYTksMCv5wiZqAxeJoBF1Pho +L5aPruJKHJwWDBNvOIf2u8g0X5IDUXlwpt/L9ZlNec1OvFefQ05rLisY+GpzjLrF +Ne85akEez3GoorKGB1s6yeHvP2UEgEcyRHCVTjFnanRbEEV16rCf0OY1/k6fi8wr +kkVbbiVghUbN0aqwdmaTd5a+g744tiROJgvM7XpWGuDpWsZkrUx6AEhEL7lAuxM+ +vhV4nYWBSipX3tUZQ9rbyltHhoMLP7YNdnhzeSJesYAfz77RP1YQmCuVh6EfnWQU +YDksswBVLuT1sw5XxJFBAJw/6KXf6vb/yPCtbVKoF6ubYfwSUTXkJf2vqmqGOQ== +-----END CERTIFICATE-----`)) + + // OU=ePKI Root Certification Authority; O=Chunghwa Telecom Co., Ltd.; C=TW + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBe +MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0 +ZC4xKjAoBgNVBAsMIWVQS0kgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe +Fw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMxMjdaMF4xCzAJBgNVBAYTAlRXMSMw +IQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEqMCgGA1UECwwhZVBL +SSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAH +SyZbCUNsIZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAh +ijHyl3SJCRImHJ7K2RKilTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3X +DZoTM1PRYfl61dd4s5oz9wCGzh1NlDivqOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1 +TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX12ruOzjjK9SXDrkb5wdJ +fzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0OWQqraffA +sgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uU +WH1+ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLS +nT0IFaUQAS2zMnaolQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pH +dmX2Os+PYhcZewoozRrSgx4hxyy/vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJip +NiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXiZo1jDiVN1Rmy5nk3pyKdVDEC +AwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/QkqiMAwGA1UdEwQF +MAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH +ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGB +uvl2ICO1J2B01GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6Yl +PwZpVnPDimZI+ymBV3QGypzqKOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkP +JXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdVxrsStZf0X4OFunHB2WyBEXYKCrC/ +gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEPNXubrjlpC2JgQCA2 +j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+rGNm6 +5ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUB +o2M3IUxExJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS +/jQ6fbjpKdx2qcgw+BRxgMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2z +Gp1iro2C6pSe3VkQw63d4k3jMdXH7OjysP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTE +W9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmODBCEIZ43ygknQW/2xzQ+D +hNQ+IIX3Sj0rnP0qCglN6oH4EZw= +-----END CERTIFICATE-----`)) + + // CN=D-TRUST BR Root CA 1 2020; O=D-Trust GmbH; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQfMmPK4TX3+oPyWWa00tNljAKBggqhkjOPQQDAzBIMQsw +CQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRS +VVNUIEJSIFJvb3QgQ0EgMSAyMDIwMB4XDTIwMDIxMTA5NDUwMFoXDTM1MDIxMTA5 +NDQ1OVowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAG +A1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDEgMjAyMDB2MBAGByqGSM49AgEGBSuB +BAAiA2IABMbLxyjR+4T1mu9CFCDhQ2tuda38KwOE1HaTJddZO0Flax7mNCq7dPYS +zuht56vkPE4/RAiLzRZxy7+SmfSk1zxQVFKQhYN4lGdnoxwJGT11NIXe7WB9xwy0 +QVK5buXuQqOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFHOREKv/ +VbNafAkl1bK6CKBrqx9tMA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6g +PKA6hjhodHRwOi8vY3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X2JyX3Jvb3Rf +Y2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVjdG9yeS5kLXRydXN0Lm5l +dC9DTj1ELVRSVVNUJTIwQlIlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxPPUQtVHJ1 +c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjO +PQQDAwNpADBmAjEAlJAtE/rhY/hhY+ithXhUkZy4kzg+GkHaQBZTQgjKL47xPoFW +wKrY7RjEsK70PvomAjEA8yjixtsrmfu3Ubgko6SUeho/5jbiA1czijDLgsfWFBHV +dWNbFJWcHwHP2NVypw87 +-----END CERTIFICATE-----`)) + + // CN=D-TRUST EV Root CA 1 2020; O=D-Trust GmbH; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQXwJB13qHfEwDo6yWjfv/0DAKBggqhkjOPQQDAzBIMQsw +CQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRS +VVNUIEVWIFJvb3QgQ0EgMSAyMDIwMB4XDTIwMDIxMTEwMDAwMFoXDTM1MDIxMTA5 +NTk1OVowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAG +A1UEAxMZRC1UUlVTVCBFViBSb290IENBIDEgMjAyMDB2MBAGByqGSM49AgEGBSuB +BAAiA2IABPEL3YZDIBnfl4XoIkqbz52Yv7QFJsnL46bSj8WeeHsxiamJrSc8ZRCC +/N/DnU7wMyPE0jL1HLDfMxddxfCxivnvubcUyilKwg+pf3VlSSowZ/Rk99Yad9rD +wpdhQntJraOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH8QARY3 +OqQo5FD4pPfsazK2/umLMA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6g +PKA6hjhodHRwOi8vY3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X2V2X3Jvb3Rf +Y2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVjdG9yeS5kLXRydXN0Lm5l +dC9DTj1ELVRSVVNUJTIwRVYlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxPPUQtVHJ1 +c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjO +PQQDAwNpADBmAjEAyjzGKnXCXnViOTYAYFqLwZOZzNnbQTs7h5kXO9XMT8oi96CA +y/m0sRtW9XLS/BnRAjEAkfcwkz8QRitxpNA7RJvAKQIFskF3UfN5Wp6OFKBOQtJb +gfM0agPnIjhQW+0ZT0MW +-----END CERTIFICATE-----`)) + + // CN=D-TRUST Root Class 3 CA 2 EV 2009; O=D-Trust GmbH; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRF +MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBD +bGFzcyAzIENBIDIgRVYgMjAwOTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUw +NDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxELVRydXN0IEdtYkgxKjAoBgNV +BAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAwOTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfSegpn +ljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM0 +3TP1YtHhzRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6Z +qQTMFexgaDbtCHu39b+T7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lR +p75mpoo6Kr3HGrHhFPC+Oh25z1uxav60sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8 +HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure3511H3a6UCAwEAAaOCASQw +ggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyvcop9Ntea +HNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFw +Oi8vZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xh +c3MlMjAzJTIwQ0ElMjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1E +RT9jZXJ0aWZpY2F0ZXJldm9jYXRpb25saXN0MEagRKBChkBodHRwOi8vd3d3LmQt +dHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xhc3NfM19jYV8yX2V2XzIwMDku +Y3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+PPoeUSbrh/Yp +3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 +nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNF +CSuGdXzfX2lXANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7na +xpeG0ILD5EJt/rDiZE4OJudANCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqX +KVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVvw9y4AyHqnxbxLFS1 +-----END CERTIFICATE-----`)) + + // CN=D-TRUST Root Class 3 CA 2 2009; O=D-Trust GmbH; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF +MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD +bGFzcyAzIENBIDIgMjAwOTAeFw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NTha +ME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMM +HkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOADER03 +UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42 +tSHKXzlABF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9R +ySPocq60vFYJfxLLHLGvKZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsM +lFqVlNpQmvH/pStmMaTJOKDfHR+4CS7zp+hnUquVH+BGPtikw8paxTGA6Eian5Rp +/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUCAwEAAaOCARowggEWMA8G +A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ4PGEMA4G +A1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVj +dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUy +MENBJTIwMiUyMDIwMDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRl +cmV2b2NhdGlvbmxpc3QwQ6BBoD+GPWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3Js +L2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAwOS5jcmwwDQYJKoZIhvcNAQEL +BQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm2H6NMLVwMeni +acfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 +o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4K +zCUqNQT4YJEVdT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8 +PIWmawomDeCTmGCufsYkl4phX5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3Y +Johw1+qRzT65ysCQblrGXnRl11z+o+I= +-----END CERTIFICATE-----`)) + + // CN=T-TeleSec GlobalRoot Class 3; OU=T-Systems Trust Center; O=T-Systems Enterprise Services GmbH; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUx +KzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAd +BgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNl +YyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgxMDAxMTAyOTU2WhcNMzMxMDAxMjM1 +OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnBy +aXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50 +ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN +8ELg63iIVl6bmlQdTQyK9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/ +RLyTPWGrTs0NvvAgJ1gORH8EGoel15YUNpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4 +hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZFiP0Zf3WHHx+xGwpzJFu5 +ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W0eDrXltM +EnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1 +A/d2O2GCahKqGFPrAyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOy +WL6ukK2YJ5f+AbGwUgC4TeQbIXQbfsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ +1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzTucpH9sry9uetuUg/vBa3wW30 +6gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7hP0HHRwA11fXT +91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml +e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4p +TpPDpFQUWw== +-----END CERTIFICATE-----`)) + + // CN=T-TeleSec GlobalRoot Class 2; OU=T-Systems Trust Center; O=T-Systems Enterprise Services GmbH; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUx +KzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAd +BgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNl +YyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgxMDAxMTA0MDE0WhcNMzMxMDAxMjM1 +OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnBy +aXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50 +ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUd +AqSzm1nzHoqvNK38DcLZSBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiC +FoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/FvudocP05l03Sx5iRUKrERLMjfTlH6VJi +1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx9702cu+fjOlbpSD8DT6Iavq +jnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGVWOHAD3bZ +wI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/ +WSA2AHmgoCJrjNXyYdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhy +NsZt+U2e+iKo4YFWz827n+qrkRk4r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPAC +uvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNfvNoBYimipidx5joifsFvHZVw +IEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR3p1m0IvVVGb6 +g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN +9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlP +BSeOE6Fuwg== +-----END CERTIFICATE-----`)) + + // CN=DigiCert TLS RSA4096 Root G5; O=DigiCert, Inc.; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBN +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMT +HERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN +NDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs +IEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS87IE+ +ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG0 +2C+JFvuUAT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgp +wgscONyfMXdcvyej/Cestyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZM +pG2T6T867jp8nVid9E6P/DsjyG244gXazOvswzH016cpVIDPRFtMbzCe88zdH5RD +nU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnVDdXifBBiqmvwPXbzP6Po +sMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9qTXeXAaDx +Zre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cd +Lvvyz6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvX +KyY//SovcfXWJL5/MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNe +XoVPzthwiHvOAbWWl9fNff2C+MIkwcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPL +tgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4EFgQUUTMc7TZArxfTJc1paPKv +TiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcN +AQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw +GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7H +PNtQOa27PShNlnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLF +O4uJ+DQtpBflF+aZfTCIITfNMBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQ +REtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/u4cnYiWB39yhL/btp/96j1EuMPik +AdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9GOUrYU9DzLjtxpdRv +/PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh47a+ +p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilw +MUc/dNAUFvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WF +qUITVuwhd4GTWgzqltlJyqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCK +ovfepEWFJqgejF0pW8hL2JpqA15w8oVPbEtoL8pU9ozaMv7Da4M/OMZ+ +-----END CERTIFICATE-----`)) + + // CN=DigiCert TLS ECC P384 Root G5; O=DigiCert, Inc.; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURp +Z2lDZXJ0IFRMUyBFQ0MgUDM4NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2 +MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ +bmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQgUm9vdCBHNTB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1TzvdlHJS +7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp +0zVozptjn4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICIS +B4CIfBFqMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 +BAMDA2gAMGUCMQCJao1H5+z8blUD2WdsJk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQ +LgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIxAJSdYsiJvRmEFOml+wG4 +DXZDjC5Ty3zfDBeWUA== +-----END CERTIFICATE-----`)) + + // CN=DigiCert Assured ID Root CA; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv +b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl +cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c +JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP +mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+ +wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4 +VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/ +AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB +AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun +pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC +dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf +fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm +NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx +H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe ++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== +-----END CERTIFICATE-----`)) + + // CN=DigiCert Assured ID Root G2; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBl +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv +b3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl +cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSA +n61UQbVH35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4Htecc +biJVMWWXvdMX0h5i89vqbFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9Hp +EgjAALAcKxHad3A2m67OeYfcgnDmCXRwVWmvo2ifv922ebPynXApVfSr/5Vh88lA +bx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OPYLfykqGxvYmJHzDNw6Yu +YjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+RnlTGNAgMB +AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQW +BBTOw0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPI +QW5pJ6d1Ee88hjZv0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I +0jJmwYrA8y8678Dj1JGG0VDjA9tzd29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4Gni +lmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAWhsI6yLETcDbYz+70CjTVW0z9 +B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0MjomZmWzwPDCv +ON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo +IhNzbM8m9Yop5w== +-----END CERTIFICATE-----`)) + + // CN=DigiCert Assured ID Root G3; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQsw +CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu +ZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3Qg +RzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBlMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJf +Zn4f5dwbRXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17Q +RSAPWXYQ1qAk8C3eNvJsKTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgFUaFNN6KDec6NHSrkhDAKBggqhkjOPQQD +AwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5FyYZ5eEJJZVrmDxxDnOOlY +JjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy1vUhZscv +6pZjamVFkpUBtA== +-----END CERTIFICATE-----`)) + + // CN=DigiCert Global Root CA; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD +QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB +CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 +nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P +T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 +gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR +TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw +DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr +hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg +06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF +PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE-----`)) + + // CN=DigiCert Global Root G2; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI +2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx +1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ +q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz +tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ +vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV +5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY +1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 +NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG +Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 +8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe +pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE-----`)) + + // CN=DigiCert Global Root G3; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQsw +CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu +ZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAe +Fw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUw +EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x +IDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0CAQYF +K4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FG +fp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPO +Z9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd +BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIx +AK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/ +oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8 +sycX +-----END CERTIFICATE-----`)) + + // CN=DigiCert High Assurance EV Root CA; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j +ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL +MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 +LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug +RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm ++9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW +PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM +xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB +Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 +hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg +EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA +FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec +nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z +eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF +hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 +Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe +vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep ++OkuE6N36B9K +-----END CERTIFICATE-----`)) + + // CN=DigiCert Trusted Root G4; OU=www.digicert.com; O=DigiCert Inc; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y +ithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If +xp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV +ySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO +DCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ +jdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/ +CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi +EhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM +fRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY +uKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK +chYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t +9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +hjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2 +SV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd ++SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc +fFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa +sjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N +cCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N +0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie +4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI +r/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1 +/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm +gKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+ +-----END CERTIFICATE-----`)) + + // CN=QuoVadis Root CA 2; O=QuoVadis Limited; C=BM + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0x +GTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJv +b3QgQ0EgMjAeFw0wNjExMjQxODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNV +BAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMRswGQYDVQQDExJRdW9W +YWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCa +GMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6XJxg +Fyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55J +WpzmM+Yklvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bB +rrcCaoF6qUWD4gXmuVbBlDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp ++ARz8un+XJiM9XOva7R+zdRcAitMOeGylZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1 +ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt66/3FsvbzSUr5R/7mp/i +Ucw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1JdxnwQ5hYIiz +PtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og +/zOhD7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UH +oycR7hYQe7xFSkyyBNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuI +yV77zGHcizN300QyNQliBJIWENieJ0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1Ud +EwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBQahGK8SEwzJQTU7tD2 +A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGUa6FJpEcwRTEL +MAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT +ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2f +BluornFdLwUvZ+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzn +g/iN/Ae42l9NLmeyhP3ZRPx3UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2Bl +fF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodmVjB3pjd4M1IQWK4/YY7yarHvGH5K +WWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK+JDSV6IZUaUtl0Ha +B0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrWIozc +hLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPR +TUIZ3Ph1WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWD +mbA4CD/pXvk1B+TJYm5Xf6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0Z +ohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y +4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8VCLAAVBpQ570su9t+Oza +8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u +-----END CERTIFICATE-----`)) + + // CN=QuoVadis Root CA 2 G3; O=QuoVadis Limited; C=BM + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL +BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc +BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00 +MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf +qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW +n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym +c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+ +O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1 +o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j +IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq +IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz +8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh +vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l +7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG +cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD +ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 +AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC +roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga +W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n +lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE ++V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV +csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd +dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg +KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM +HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4 +WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M +-----END CERTIFICATE-----`)) + + // CN=QuoVadis Root CA 3 G3; O=QuoVadis Limited; C=BM + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQEL +BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc +BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00 +MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMgRzMwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286IxSR +/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNu +FoM7pmRLMon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXR +U7Ox7sWTaYI+FrUoRqHe6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+c +ra1AdHkrAj80//ogaX3T7mH1urPnMNA3I4ZyYUUpSFlob3emLoG+B01vr87ERROR +FHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3UVDmrJqMz6nWB2i3ND0/k +A9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f75li59wzw +eyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634Ryl +sSqiMd5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBp +VzgeAVuNVejH38DMdyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0Q +A4XN8f+MFrXBsj6IbGB/kE+V9/YtrQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ +ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZIhvcNAQELBQAD +ggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px +KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnI +FUBhynLWcKzSt/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5Wvv +oxXqA/4Ti2Tk08HS6IT7SdEQTXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFg +u/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9DuDcpmvJRPpq3t/O5jrFc/ZSXPsoaP +0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGibIh6BJpsQBJFxwAYf +3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmDhPbl +8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+ +DhcI00iX0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HN +PlopNLk9hM6xZdRZkZFWdSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ +ywaZWWDYWGWVjUTR939+J399roD1B0y2PpxxVJkES/1Y+Zj0 +-----END CERTIFICATE-----`)) + + // CN=CA Disig Root R2; O=Disig a.s.; L=Bratislava; C=SK + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNV +BAYTAlNLMRMwEQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMu +MRkwFwYDVQQDExBDQSBEaXNpZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQy +MDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sxEzARBgNVBAcTCkJyYXRpc2xhdmEx +EzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERpc2lnIFJvb3QgUjIw +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbCw3Oe +NcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNH +PWSb6WiaxswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3I +x2ymrdMxp7zo5eFm1tL7A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbe +QTg06ov80egEFGEtQX6sx3dOy1FU+16SGBsEWmjGycT6txOgmLcRK7fWV8x8nhfR +yyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqVg8NTEQxzHQuyRpDRQjrO +QG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa5Beny912 +H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJ +QfYEkoopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUD +i/ZnWejBBhG93c+AAk9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORs +nLMOPReisjQS1n6yqEm70XooQL6iFh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1 +rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5uQu0wDQYJKoZI +hvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM +tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqf +GopTpti72TVVsRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkb +lvdhuDvEK7Z4bLQjb/D907JedR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka ++elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W81k/BfDxujRNt+3vrMNDcTa/F1bal +TFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjxmHHEt38OFdAlab0i +nSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01utI3 +gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18Dr +G5gPcFw0sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3Os +zMOl6W8KjptlwlCFtaOgUxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8x +L4ysEr3vQCj8KWefshNPZiTEUxnpHikV7+ZtsH8tZ/3zbBt1RqPlShfppNcL +-----END CERTIFICATE-----`)) + + // CN=emSign ECC Root CA - G3; OU=emSign PKI; O=eMudhra Technologies Limited; C=IN + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG +EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo +bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ +TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s +b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0 +WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS +fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB +zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq +hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB +CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD ++JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE-----`)) + + // CN=emSign Root CA - G1; OU=emSign PKI; O=eMudhra Technologies Limited; C=IN + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD +VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU +ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH +MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO +MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv +Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz +f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO +8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq +d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM +tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt +Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB +o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD +AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x +PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM +wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d +GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH +6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby +RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx +iN66zB+Afko= +-----END CERTIFICATE-----`)) + + // CN=AffirmTrust Commercial; O=AffirmTrust; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UE +BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz +dCBDb21tZXJjaWFsMB4XDTEwMDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDEL +MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp +cm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6EqdbDuKP +Hx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yr +ba0F8PrVC8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPAL +MeIrJmqbTFeurCA+ukV6BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1 +yHp52UKqK39c/s4mT6NmgTWvRLpUHhwwMmWd5jyTXlBOeuM61G7MGvv50jeuJCqr +VwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNVHQ4EFgQUnZPGU4teyq8/ +nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ +KoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYG +XUPGhi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNj +vbz4YYCanrHOQnDiqX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivt +Z8SOyUOyXGsViQK8YvxO8rUzqrJv0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9g +N53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0khsUlHRUe072o0EclNmsxZt9YC +nlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= +-----END CERTIFICATE-----`)) + + // CN=Atos TrustedRoot 2011; O=Atos; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UE +AwwVQXRvcyBUcnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQG +EwJERTAeFw0xMTA3MDcxNDU4MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMM +FUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsGA1UECgwEQXRvczELMAkGA1UEBhMC +REUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVhTuXbyo7LjvPpvMp +Nb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr54rM +VD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+ +SZFhyBH+DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ +4J7sVaE3IqKHBAUsR320HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0L +cp2AMBYHlT8oDv3FdU9T1nSatCQujgKRz3bFmx5VdJx4IbHwLfELn8LVlhgf8FQi +eowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7Rl+lwrrw7GWzbITAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZbNshMBgG +A1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3 +DQEBCwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8j +vZfza1zv7v1Apt+hk6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kP +DpFrdRbhIfzYJsdHt6bPWHJxfrrhTZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pc +maHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a961qn8FYiqTxlVMYVqL2Gns2D +lmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G3mB/ufNPRJLv +KrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed +-----END CERTIFICATE-----`)) + + // CN=Atos TrustedRoot Root CA ECC TLS 2021; O=Atos; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICFTCCAZugAwIBAgIQPZg7pmY9kGP3fiZXOATvADAKBggqhkjOPQQDAzBMMS4w +LAYDVQQDDCVBdG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgRUNDIFRMUyAyMDIxMQ0w +CwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0yMTA0MjIwOTI2MjNaFw00MTA0 +MTcwOTI2MjJaMEwxLjAsBgNVBAMMJUF0b3MgVHJ1c3RlZFJvb3QgUm9vdCBDQSBF +Q0MgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNVBAYTAkRFMHYwEAYHKoZI +zj0CAQYFK4EEACIDYgAEloZYKDcKZ9Cg3iQZGeHkBQcfl+3oZIK59sRxUM6KDP/X +tXa7oWyTbIOiaG6l2b4siJVBzV3dscqDY4PMwL502eCdpO5KTlbgmClBk1IQ1SQ4 +AjJn8ZQSb+/Xxd4u/RmAo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR2 +KCXWfeBmmnoJsmo7jjPXNtNPojAOBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMD +aAAwZQIwW5kp85wxtolrbNa9d+F851F+uDrNozZffPc8dz7kUK2o59JZDCaOMDtu +CCrCp1rIAjEAmeMM56PDr9NJLkaCI2ZdyQAUEv049OGYa3cpetskz2VAv9LcjBHo +9H1/IISpQuQo +-----END CERTIFICATE-----`)) + + // CN=Atos TrustedRoot Root CA RSA TLS 2021; O=Atos; C=DE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFZDCCA0ygAwIBAgIQU9XP5hmTC/srBRLYwiqipDANBgkqhkiG9w0BAQwFADBM +MS4wLAYDVQQDDCVBdG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgUlNBIFRMUyAyMDIx +MQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0yMTA0MjIwOTIxMTBaFw00 +MTA0MTcwOTIxMDlaMEwxLjAsBgNVBAMMJUF0b3MgVHJ1c3RlZFJvb3QgUm9vdCBD +QSBSU0EgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNVBAYTAkRFMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtoAOxHm9BYx9sKOdTSJNy/BBl01Z +4NH+VoyX8te9j2y3I49f1cTYQcvyAh5x5en2XssIKl4w8i1mx4QbZFc4nXUtVsYv +Ye+W/CBGvevUez8/fEc4BKkbqlLfEzfTFRVOvV98r61jx3ncCHvVoOX3W3WsgFWZ +kmGbzSoXfduP9LVq6hdKZChmFSlsAvFr1bqjM9xaZ6cF4r9lthawEO3NUDPJcFDs +GY6wx/J0W2tExn2WuZgIWWbeKQGb9Cpt0xU6kGpn8bRrZtkh68rZYnxGEFzedUln +nkL5/nWpo63/dgpnQOPF943HhZpZnmKaau1Fh5hnstVKPNe0OwANwI8f4UDErmwh +3El+fsqyjW22v5MvoVw+j8rtgI5Y4dtXz4U2OLJxpAmMkokIiEjxQGMYsluMWuPD +0xeqqxmjLBvk1cbiZnrXghmmOxYsL3GHX0WelXOTwkKBIROW1527k2gV+p2kHYzy +geBYBr3JtuP2iV2J+axEoctr+hbxx1A9JNr3w+SH1VbxT5Aw+kUJWdo0zuATHAR8 +ANSbhqRAvNncTFd+rrcztl524WWLZt+NyteYr842mIycg5kDcPOvdO3GDjbnvezB +c6eUWsuSZIKmAMFwoW4sKeFYV+xafJlrJaSQOoD0IJ2azsct+bJLKZWD6TWNp0lI +pw9MGZHQ9b8Q4HECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +dEmZ0f+0emhFdcN+tNzMzjkz2ggwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +DAUAA4ICAQAjQ1MkYlxt/T7Cz1UAbMVWiLkO3TriJQ2VSpfKgInuKs1l+NsW4AmS +4BjHeJi78+xCUvuppILXTdiK/ORO/auQxDh1MoSf/7OwKwIzNsAQkG8dnK/haZPs +o0UvFJ/1TCplQ3IM98P4lYsU84UgYt1UU90s3BiVaU+DR3BAM1h3Egyi61IxHkzJ +qM7F78PRreBrAwA0JrRUITWXAdxfG/F851X6LWh3e9NpzNMOa7pNdkTWwhWaJuyw +xfW70Xp0wmzNxbVe9kzmWy2B27O3Opee7c9GslA9hGCZcbUztVdF5kJHdWoOsAgM +rr3e97sPWD2PAzHoPYJQyi9eDF20l74gNAf0xBLh7tew2VktafcxBPTy+av5EzH4 +AXcOPUIjJsyacmdRIXrMPIWo6iFqO9taPKU0nprALN+AnCng33eU0aKAQv9qTFsR +0PXNor6uzFFcw9VUewyu1rkGd4Di7wcaaMxZUa1+XGdrudviB0JbuAEFWDlN5LuY +o7Ey7Nmj1m+UI/87tyll5gfp77YZ6ufCOB0yiJA8EytuzO+rdwY0d4RPcuSBhPm5 +dDTedk+SKlOxJTnbPP/lPqYO5Wue/9vsL3SD3460s6neFE3/MaNFcyT6lSnMEpcE +oji2jbDwN/zIIX8/syQbPYtuzE2wFg2WHYMfRsCbvUOZ58SWLs5fyQ== +-----END CERTIFICATE-----`)) + + // CN=GlobalSign; OU=GlobalSign Root CA - R6; O=GlobalSign + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEg +MB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2Jh +bFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQx +MjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjET +MBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQssgrRI +xutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1k +ZguSgMpE3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxD +aNc9PIrFsmbVkJq3MQbFvuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJw +LnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqMPKq0pPbzlUoSB239jLKJz9CgYXfIWHSw +1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+azayOeSsJDa38O+2HBNX +k7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05OWgtH8wY2 +SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/h +bguyCLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4n +WUx2OVvq+aWh2IMP0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpY +rZxCRXluDocZXFSxZba/jJvcE+kNb7gu3GduyYsRtYQUigAZcIN5kZeR1Bonvzce +MgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNVHSMEGDAWgBSu +bAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN +nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGt +Ixg93eFyRJa0lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr61 +55wsTLxDKZmOMNOsIeDjHfrYBzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLj +vUYAGm0CuiVdjaExUd1URhxN25mW7xocBFymFe944Hn+Xds+qkxV/ZoVqW/hpvvf +cDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr3TsTjxKM4kEaSHpz +oHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB10jZp +nOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfs +pA9MRf/TuTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+v +JJUEeKgDu+6B5dpffItKoZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R +8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+tJDfLRVpOoERIyNiwmcUVhAn21klJwGW4 +5hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= +-----END CERTIFICATE-----`)) + + // CN=GlobalSign Root E46; O=GlobalSign nv-sa; C=BE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx +CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD +ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw +MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex +HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq +R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd +yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ +7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 ++RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= +-----END CERTIFICATE-----`)) + + // CN=GlobalSign Root R46; O=GlobalSign nv-sa; C=BE + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUA +MEYxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYD +VQQDExNHbG9iYWxTaWduIFJvb3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMy +MDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt +c2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08EsCVeJ +OaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQG +vGIFAha/r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud +316HCkD7rRlr+/fKYIje2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo +0q3v84RLHIf8E6M6cqJaESvWJ3En7YEtbWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSE +y132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvjK8Cd+RTyG/FWaha/LIWF +zXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD412lPFzYE ++cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCN +I/onccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzs +x2sZy/N78CsHpdlseVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqa +ByFrgY/bxFn63iLABJzjqls2k+g9vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC +4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEMBQADggIBAHx4 +7PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg +JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti +2kM3S+LGteWygxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIk +pnnpHs6i58FZFZ8d4kuaPp92CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRF +FRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZmOUdkLG5NrmJ7v2B0GbhWrJKsFjLt +rWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qqJZ4d16GLuc1CLgSk +ZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwyeqiv5 +u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP +4vkYxboznxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6 +N3ec592kD3ZDZopD8p/7DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3 +vouXsXgxT7PntgMTzlSdriVZzH81Xwj3QEUxeCp6 +-----END CERTIFICATE-----`)) + + // CN=GlobalSign; OU=GlobalSign ECC Root CA - R5; O=GlobalSign + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEk +MCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpH +bG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoX +DTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBD +QSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWdu +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6SFkc +8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8ke +hOvRnkmSh5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYI +KoZIzj0EAwMDaAAwZQIxAOVpEslu28YxuglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg +515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7yFz9SO8NdCKoCOJuxUnO +xwy8p2Fp8fc74SrL+SvzZpA3 +-----END CERTIFICATE-----`)) + + // CN=GlobalSign; OU=GlobalSign Root CA - R3; O=GlobalSign + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4 +MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8 +RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT +gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm +KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd +QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ +XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw +DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o +LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU +RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp +jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK +6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX +mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs +Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH +WD9f +-----END CERTIFICATE-----`)) + + // CN=Starfield Root Certificate Authority - G2; O=Starfield Technologies, Inc.; L=Scottsdale; ST=Arizona; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT +HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs +ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw +MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 +b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj +aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp +Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg +nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 +HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N +Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN +dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 +HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G +CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU +sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 +4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg +8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K +pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 +mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 +-----END CERTIFICATE-----`)) + + // CN=Go Daddy Root Certificate Authority - G2; O=GoDaddy.com, Inc.; L=Scottsdale; ST=Arizona; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT +EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp +ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz +NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH +EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE +AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD +E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH +/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy +DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh +GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR +tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA +AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX +WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu +9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr +gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo +2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO +LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI +4uJEvlz36hz1 +-----END CERTIFICATE-----`)) + + // CN=GlobalSign; OU=GlobalSign ECC Root CA - R4; O=GlobalSign + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgINAgPlfvU/k/2lCSGypjAKBggqhkjOPQQDAjBQMSQwIgYD +VQQLExtHbG9iYWxTaWduIEVDQyBSb290IENBIC0gUjQxEzARBgNVBAoTCkdsb2Jh +bFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMTIxMTEzMDAwMDAwWhcNMzgw +MTE5MDMxNDA3WjBQMSQwIgYDVQQLExtHbG9iYWxTaWduIEVDQyBSb290IENBIC0g +UjQxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wWTAT +BgcqhkjOPQIBBggqhkjOPQMBBwNCAAS4xnnTj2wlDp8uORkcA6SumuU5BwkWymOx +uYb4ilfBV85C+nOh92VC/x7BALJucw7/xyHlGKSq2XE/qNS5zowdo0IwQDAOBgNV +HQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/ ++wpu+74zyTyjhNUwCgYIKoZIzj0EAwIDRwAwRAIgIk90crlgr/HmnKAWBVBfw147 +bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm +-----END CERTIFICATE-----`)) + + // CN=GTS Root R4; O=Google Trust Services LLC; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi +QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR +HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 +p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE-----`)) + + // CN=GTS Root R2; O=Google Trust Services LLC; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw +MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp +Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt +nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY +6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu +MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k +RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg +f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV ++3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo +dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW +Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa +G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq +gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID +AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H +vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 +0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC +B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u +NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg +yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev +HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6 +xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR +TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg +JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV +7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl +6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL +-----END CERTIFICATE-----`)) + + // CN=GTS Root R1; O=Google Trust Services LLC; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw +MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp +Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo +27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w +Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw +TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl +qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH +szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8 +Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk +MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92 +wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p +aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN +VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID +AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb +C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe +QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy +h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4 +7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J +ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef +MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/ +Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT +6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ +0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm +2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb +bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c +-----END CERTIFICATE-----`)) + + // CN=GTS Root R3; O=Google Trust Services LLC; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G +jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2 +4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7 +VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm +ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X +-----END CERTIFICATE-----`)) + + // CN=ACCVRAIZ1; OU=PKIACCV; O=ACCV; C=ES + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE +AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw +CQYDVQQGEwJFUzAeFw0xMTA1MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQ +BgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwHUEtJQUNDVjENMAsGA1UECgwEQUND +VjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCb +qau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gMjmoY +HtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWo +G2ioPej0RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpA +lHPrzg5XPAOBOp0KoVdDaaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhr +IA8wKFSVf+DuzgpmndFALW4ir50awQUZ0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/ +0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDGWuzndN9wrqODJerWx5eH +k6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs78yM2x/47 +4KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMO +m3WR5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpa +cXpkatcnYGMN285J9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPl +uUsXQA+xtrn13k/c4LOsOxFwYIRKQ26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYI +KwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRwOi8vd3d3LmFjY3YuZXMvZmls +ZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEuY3J0MB8GCCsG +AQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 +VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeT +VfZW6oHlNsyMHj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIG +CCsGAQUFBwICMIIBFB6CARAAQQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUA +cgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBhAO0AegAgAGQAZQAgAGwAYQAgAEEA +QwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUAYwBuAG8AbABvAGcA +7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBjAHQA +cgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAA +QwBQAFMAIABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUA +czAwBggrBgEFBQcCARYkaHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2Mu +aHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRt +aW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2MV9kZXIuY3JsMA4GA1Ud +DwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZIhvcNAQEF +BQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdp +D70ER9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gU +JyCpZET/LtZ1qmxNYEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+m +AM/EKXMRNt6GGT6d7hmKG9Ww7Y49nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepD +vV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJTS+xJlsndQAJxGJ3KQhfnlms +tn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3sCPdK6jT2iWH +7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h +I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szA +h1xA2syVP1XgNce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xF +d3+YJ5oyXSrjhO7FmGYvliAd3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2H +pPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3pEfbRD0tVNEYqi4Y7 +-----END CERTIFICATE-----`)) + + // OU=AC RAIZ FNMT-RCM; O=FNMT-RCM; C=ES + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsx +CzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJ +WiBGTk1ULVJDTTAeFw0wODEwMjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJ +BgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBG +Tk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALpxgHpMhm5/ +yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcfqQgf +BBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAz +WHFctPVrbtQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxF +tBDXaEAUwED653cXeuYLj2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z +374jNUUeAlz+taibmSXaXvMiwzn15Cou08YfxGyqxRxqAQVKL9LFwag0Jl1mpdIC +IfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mwWsXmo8RZZUc1g16p6DUL +mbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnTtOmlcYF7 +wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peS +MKGJ47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2 +ZSysV4999AeU14ECll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMet +UqIJ5G+GR4of6ygnXYMgrwTJbFaai0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFPd9xf3E6Jobd2Sn9R2gzL+H +YJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1odHRwOi8vd3d3 +LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD +nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1 +RXxlDPiyN8+sD8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYM +LVN0V2Ue1bLdI4E7pWYjJ2cJj+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf +77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrTQfv6MooqtyuGC2mDOL7Nii4LcK2N +JpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW+YJF1DngoABd15jm +fZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7Ixjp +6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp +1txyM/1d8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B +9kiABdcPUXmsEKvU7ANm5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wok +RqEIr9baRRmW1FMdW4R58MD3R++Lj8UGrp1MYp3/RgT408m2ECVAdf4WqslKYIYv +uu8wd+RU4riEmViAqhOLUTpPSPaLtrM= +-----END CERTIFICATE-----`)) + + // CN=AC RAIZ FNMT-RCM SERVIDORES SEGUROS; OU=Ceres; O=FNMT-RCM; C=ES; OrganizationIdentifier=VATES-Q2826004J + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICbjCCAfOgAwIBAgIQYvYybOXE42hcG2LdnC6dlTAKBggqhkjOPQQDAzB4MQsw +CQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVzMRgw +FgYDVQRhDA9WQVRFUy1RMjgyNjAwNEoxLDAqBgNVBAMMI0FDIFJBSVogRk5NVC1S +Q00gU0VSVklET1JFUyBTRUdVUk9TMB4XDTE4MTIyMDA5MzczM1oXDTQzMTIyMDA5 +MzczM1oweDELMAkGA1UEBhMCRVMxETAPBgNVBAoMCEZOTVQtUkNNMQ4wDAYDVQQL +DAVDZXJlczEYMBYGA1UEYQwPVkFURVMtUTI4MjYwMDRKMSwwKgYDVQQDDCNBQyBS +QUlaIEZOTVQtUkNNIFNFUlZJRE9SRVMgU0VHVVJPUzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABPa6V1PIyqvfNkpSIeSX0oNnnvBlUdBeh8dHsVnyV0ebAAKTRBdp20LH +sbI6GA60XYyzZl2hNPk2LEnb80b8s0RpRBNm/dfF/a82Tc4DTQdxz69qBdKiQ1oK +Um8BA06Oi6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD +VR0OBBYEFAG5L++/EYZg8k/QQW6rcx/n0m5JMAoGCCqGSM49BAMDA2kAMGYCMQCu +SuMrQMN0EfKVrRYj3k4MGuZdpSRea0R7/DjiT8ucRRcRTBQnJlU5dUoDzBOQn5IC +MQD6SmxgiHPz7riYYqnOK8LZiqZwMR2vsJRM60/G49HzYqc8/5MuB1xJAWdpEgJy +v+c= +-----END CERTIFICATE-----`)) + + // CN=TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1; OU=Kamu Sertifikasyon Merkezi - Kamu SM; O=Turkiye Bilimsel ve Teknolojik Arastirma Kurumu - TUBITAK; L=Gebze - Kocaeli; C=TR + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIx +GDAWBgNVBAcTD0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxp +bXNlbCB2ZSBUZWtub2xvamlrIEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0w +KwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24gTWVya2V6aSAtIEthbXUgU00xNjA0 +BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRpZmlrYXNpIC0gU3Vy +dW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYDVQQG +EwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXll +IEJpbGltc2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklU +QUsxLTArBgNVBAsTJEthbXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBT +TTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11IFNNIFNTTCBLb2sgU2VydGlmaWthc2kg +LSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr3UwM6q7 +a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y86Ij5iySr +LqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INr +N3wcwv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2X +YacQuFWQfw4tJzh03+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/ +iSIzL+aFCr2lqBs23tPcLG07xxO9WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4f +AJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQUZT/HiobGPN08VFw1+DrtUgxH +V8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh +AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPf +IPP54+M638yclNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4 +lzwDGrpDxpa5RXI4s6ehlj2Re37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c +8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0jq5Rm+K37DwhuJi1/FwcJsoz7UMCf +lo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= +-----END CERTIFICATE-----`)) + + // CN=HARICA TLS RSA Root CA 2021; O=Hellenic Academic and Research Institutions CA; C=GR + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBs +MQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl +c2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0Eg +Um9vdCBDQSAyMDIxMB4XDTIxMDIxOTEwNTUzOFoXDTQ1MDIxMzEwNTUzN1owbDEL +MAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl +YXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNBIFJv +b3QgQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569l +mwVnlskNJLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FHws+RFjZiPTgE +4VGC/6zStGndLuwRo0Xua2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uv +a9of08WRiFukiZLRgeaMOVig1mlDqa2YUlhu2wr7a89o+uOkXjpFc5gH6l8Cct4M +pbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K5FrZx40d/JiZ+yykgmvw +Kh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEvdmn8kN3b +LW7R8pv1GmuebxWMevBLKKAiOIAkbDakO/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcY +AuUR0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqB +AGMUuTNe3QvboEUHGjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYq +E613TBoYm5EPWNgGVMWX+Ko/IIqmhaZ39qb8HOLubpQzKoNQhArlT4b4UEV4AIHr +W2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQCPxrvrNQKlr9qEgYRtaQQJKQ +CoReaDH46+0N0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLuMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAU +X15QvWiWkKQUEapobQk1OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3 +f5Z2EMVGpdAgS1D0NTsY9FVqQRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar5AWMxaja +H6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxDQpSbIPDRzbLrLFPCU3hKTwSUQZqP +JzLB5UkZv/HywouoCjkxKLR9YjYsTewfM7Z+d21+UPCfDtcRj88YxeMn/ibvBZ3P +zzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5vZSt +jBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0 +/L5H9MG0qPzVMIho7suuyWPEdr6sOBjhXlzPrjoiUevRi7PzKzMHVIf6tLITe7pT +BGIBnfHAT+7hOtSLIBD6Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79 +aPib8qXPMThcFarmlwDB31qlpzmq6YR/PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YW +xw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnnkf3/W9b3raYvAwtt41dU +63ZTGI0RmLo= +-----END CERTIFICATE-----`)) + + // CN=HARICA TLS ECC Root CA 2021; O=Hellenic Academic and Research Institutions CA; C=GR + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICVDCCAdugAwIBAgIQZ3SdjXfYO2rbIvT/WeK/zjAKBggqhkjOPQQDAzBsMQsw +CQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2Vh +cmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBFQ0MgUm9v +dCBDQSAyMDIxMB4XDTIxMDIxOTExMDExMFoXDTQ1MDIxMzExMDEwOVowbDELMAkG +A1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj +aCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgRUNDIFJvb3Qg +Q0EgMjAyMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDgI/rGgltJ6rK9JOtDA4MM7 +KKrxcm1lAEeIhPyaJmuqS7psBAqIXhfyVYf8MLA04jRYVxqEU+kw2anylnTDUR9Y +STHMmE5gEYd103KUkE+bECUqqHgtvpBBWJAVcqeht6NCMEAwDwYDVR0TAQH/BAUw +AwEB/zAdBgNVHQ4EFgQUyRtTgRL+BNUW0aq8mm+3oJUZbsowDgYDVR0PAQH/BAQD +AgGGMAoGCCqGSM49BAMDA2cAMGQCMBHervjcToiwqfAircJRQO9gcS3ujwLEXQNw +SaSS6sUUiHCm0w2wqsosQJz76YJumgIwK0eaB8bRwoF8yguWGEEbo/QwCZ61IygN +nxS2PFOiTAZpffpskcYqSUXm7LcT4Tps +-----END CERTIFICATE-----`)) + + // CN=IdenTrust Commercial Root CA 1; O=IdenTrust; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBK +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVu +VHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQw +MTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScw +JQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ldhNlT +3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU ++ehcCuz/mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gp +S0l4PJNgiCL8mdo2yMKi1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1 +bVoE/c40yiTcdCMbXTMTEl3EASX2MN0CXZ/g1Ue9tOsbobtJSdifWwLziuQkkORi +T0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl3ZBWzvurpWCdxJ35UrCL +vYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzyNeVJSQjK +Vsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZK +dHzVWYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHT +c+XvvqDtMwt0viAgxGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hv +l7yTmvmcEpB4eoCHFddydJxVdHixuuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5N +iGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZIhvcNAQELBQAD +ggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH +6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwt +LRvM7Kqas6pgghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93 +nAbowacYXVKV7cndJZ5t+qntozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3 ++wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmVYjzlVYA211QC//G5Xc7UI2/YRYRK +W2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUXfeu+h1sXIFRRk0pT +AwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/rokTLq +l1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG +4iZZRHUe2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZ +mUlO+KWA2yUPHGNiiskzZ2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A +7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7RcGzM7vRX+Bi6hG6H +-----END CERTIFICATE-----`)) + + // CN=ISRG Root X1; O=Internet Security Research Group; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE-----`)) + + // CN=ISRG Root X2; O=Internet Security Research Group; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw +CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg +R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00 +MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT +ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW ++1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9 +ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI +zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW +tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 +/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE-----`)) + + // CN=Izenpe.com; O=IZENPE S.A.; C=ES + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4 +MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6 +ZW5wZS5jb20wHhcNMDcxMjEzMTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYD +VQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5j +b20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ03rKDx6sp4boFmVq +scIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAKClaO +xdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6H +LmYRY2xU+zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFX +uaOKmMPsOzTFlUFpfnXCPCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQD +yCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxTOTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+ +JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbKF7jJeodWLBoBHmy+E60Q +rLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK0GqfvEyN +BjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8L +hij+0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIB +QFqNeb+Lz0vPqhbBleStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+ +HMh3/1uaD7euBUbl8agW7EekFwIDAQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2lu +Zm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+SVpFTlBFIFMuQS4gLSBDSUYg +QTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBGNjIgUzgxQzBB +BgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx +MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUA +A4ICAQB4pgwWSp9MiDrAyw6lFn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWb +laQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbgakEyrkgPH7UIBzg/YsfqikuFgba56 +awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8qhT/AQKM6WfxZSzwo +JNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Csg1lw +LDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCT +VyvehQP5aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGk +LhObNA5me0mrZJfQRsN5nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJb +UjWumDqtujWTI6cfSN01RpiyEGjkpTHCClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/ +QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZoQ0iy2+tzJOeRf1SktoA+ +naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1ZWrOZyGls +QyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== +-----END CERTIFICATE-----`)) + + // CN=SZAFIR ROOT CA2; O=Krajowa Izba Rozliczeniowa S.A.; C=PL + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQEL +BQAwUTELMAkGA1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6 +ZW5pb3dhIFMuQS4xGDAWBgNVBAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkw +NzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJBgNVBAYTAlBMMSgwJgYDVQQKDB9L +cmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYDVQQDDA9TWkFGSVIg +Uk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5QqEvN +QLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT +3PSQ1hNKDJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw +3gAeqDRHu5rr/gsUvTaE2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr6 +3fE9biCloBK0TXC5ztdyO4mTp4CEHCdJckm1/zuVnsHMyAHs6A6KCpbns6aH5db5 +BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwiieDhZNRnvDF5YTy7ykHN +XGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsF +AAOCAQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw +8PRBEew/R40/cof5O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOG +nXkZ7/e7DDWQw4rtTw/1zBLZpD67oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCP +oky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul4+vJhaAlIDf7js4MNIThPIGy +d05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6+/NNIxuZMzSg +LvWpCz/UXeHPhJ/iGcJfitYgHuNztw== +-----END CERTIFICATE-----`)) + + // CN=e-Szigno Root CA 2017; O=Microsec Ltd.; L=Budapest; C=HU; OrganizationIdentifier=VATHU-23584497 + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNV +BAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRk +LjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJv +b3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZaFw00MjA4MjIxMjA3MDZaMHExCzAJ +BgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMg +THRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25v +IFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtv +xie+RJCxs1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+H +Wyx7xf58etqjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBSHERUI0arBeAyxr87GyZDvvzAEwDAfBgNVHSMEGDAWgBSHERUI0arB +eAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEAtVfd14pVCzbhhkT61Nlo +jbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxOsvxyqltZ ++efcMQ== +-----END CERTIFICATE-----`)) + + // CN=Microsec e-Szigno Root CA 2009; O=Microsec Ltd.; L=Budapest; C=HU; EmailAddress=info@e-szigno.hu + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYD +VQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0 +ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0G +CSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTAeFw0wOTA2MTYxMTMwMThaFw0y +OTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3Qx +FjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUtU3pp +Z25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o +dTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvP +kd6mJviZpWNwrZuuyjNAfW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tc +cbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG0IMZfcChEhyVbUr02MelTTMuhTlAdX4U +fIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKApxn1ntxVUwOXewdI/5n7 +N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm1HxdrtbC +xkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1 ++rUCAwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPM +Pcu1SCOhGnqmKrs0aDAbBgNVHREEFDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqG +SIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0olZMEyL/azXm4Q5DwpL7v8u8h +mLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfXI/OMn74dseGk +ddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 +tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c +2Pm2G2JwCz02yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5t +HMN1Rq41Bab2XD0h7lbwyYIiLXpUq3DDfSJlgnCW +-----END CERTIFICATE-----`)) + + // CN=Microsoft ECC Root Certificate Authority 2017; O=Microsoft Corporation; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQsw +CQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYD +VQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIw +MTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4MjMxNjA0WjBlMQswCQYDVQQGEwJV +UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNy +b3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZR +ogPZnZH6thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYb +hGBKia/teQ87zvH2RPUBeMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBTIy5lycFIM+Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3 +FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlfXu5gKcs68tvWMoQZP3zV +L8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaReNtUjGUB +iudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M= +-----END CERTIFICATE-----`)) + + // CN=Microsoft RSA Root Certificate Authority 2017; O=Microsoft Corporation; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBl +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw +NAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 +IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQG +EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1N +aWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZ +Nt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0 +ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1 +HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztm +gGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XFWvLJ +jEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUc +aDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaG +YaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6 +W6IYZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4K +UGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH ++FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+Q +W5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZC +LgLNFgVZJ8og6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OC +gMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6 +tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nh +SnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2 +TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3 +pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGR +xpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiApp +GWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9 +dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hN +AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB +RA+GsCyRxj3qrg+E +-----END CERTIFICATE-----`)) + + // CN=NAVER Global Root Certification Authority; O=NAVER BUSINESS PLATFORM Corp.; C=KR + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEM +BQAwaTELMAkGA1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRG +T1JNIENvcnAuMTIwMAYDVQQDDClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4NDJaFw0zNzA4MTgyMzU5NTlaMGkx +CzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVTUyBQTEFURk9STSBD +b3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVA +iQqrDZBbUGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH +38dq6SZeWYp34+hInDEW+j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lE +HoSTGEq0n+USZGnQJoViAbbJAh2+g1G7XNr4rRVqmfeSVPc0W+m/6imBEtRTkZaz +kVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2aacp+yPOiNgSnABIqKYP +szuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4Yb8Obtoq +vC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHf +nZ3zVHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaG +YQ5fG8Ir4ozVu53BA0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo +0es+nPxdGoMuK8u180SdOqcXYZaicdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3a +CJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejyYhbLgGvtPe31HzClrkvJE+2K +AQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNVHQ4EFgQU0p+I +36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoN +qo0hV4/GPnrK21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatj +cu3cvuzHV+YwIHHW1xDBE1UBjCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm ++LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bxhYTeodoS76TiEJd6eN4MUZeoIUCL +hr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTgE34h5prCy8VCZLQe +lHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTHD8z7 +p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8 +piKCk5XQA76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLR +LBT/DShycpWbXgnbiUSYqqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX +5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oGI/hGoiLtk/bdmuYqh7GYVPEi92tF4+KO +dh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmgkpzNNIaRkPpkUZ3+/uul +9XXeifdy +-----END CERTIFICATE-----`)) + + // CN=NetLock Arany (Class Gold) Főtanúsítvány; OU=Tanúsítványkiadók (Certification Services); O=NetLock Kft.; L=Budapest; C=HU + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQG +EwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3 +MDUGA1UECwwuVGFuw7pzw610dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNl +cnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBBcmFueSAoQ2xhc3MgR29sZCkgRsWR +dGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgxMjA2MTUwODIxWjCB +pzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxOZXRM +b2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlm +aWNhdGlvbiBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNz +IEdvbGQpIEbFkXRhbsO6c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAxCRec75LbRTDofTjl5Bu0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrT +lF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw/HpYzY6b7cNGbIRwXdrz +AZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAkH3B5r9s5 +VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRG +ILdwfzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2 +BJtr+UBdADTHLpl1neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAG +AQH/AgEEMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2M +U9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwWqZw8UQCgwBEIBaeZ5m8BiFRh +bvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTtaYtOUZcTh5m2C ++C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC +bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2F +uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2 +XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= +-----END CERTIFICATE-----`)) + + // CN=OISTE WISeKey Global Root GC CA; OU=OISTE Foundation Endorsed; O=WISeKey; C=CH + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQsw +CQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91 +bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwg +Um9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRaFw00MjA1MDkwOTU4MzNaMG0xCzAJ +BgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQLExlPSVNURSBGb3Vu +ZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2JhbCBS +b290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4ni +eUqjFqdrVCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4W +p2OQ0jnUsYd4XxiWD1AbNTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7T +rYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0EAwMDaAAwZQIwJsdpW9zV +57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtkAjEA2zQg +Mgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9 +-----END CERTIFICATE-----`)) + + // CN=OISTE WISeKey Global Root GB CA; OU=OISTE Foundation Endorsed; O=WISeKey; C=CH + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt +MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg +Rm91bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9i +YWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAwMzJaFw0zOTEyMDExNTEwMzFaMG0x +CzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQLExlPSVNURSBG +b3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh +bCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3 +HEokKtaXscriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGx +WuR51jIjK+FTzJlFXHtPrby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX +1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNk +u7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4oQnc/nSMbsrY9gBQHTC5P +99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvgGUpuuy9r +M2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUw +AwEB/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUB +BAMCAQAwDQYJKoZIhvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrgh +cViXfa43FK8+5/ea4n32cZiZBKpDdHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5 +gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0VQreUGdNZtGn//3ZwLWoo4rO +ZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEuiHZeeevJuQHHf +aPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic +Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= +-----END CERTIFICATE-----`)) + + // CN=Security Communication ECC RootCA1; O=SECOM Trust Systems CO.,LTD.; C=JP + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICODCCAb6gAwIBAgIJANZdm7N4gS7rMAoGCCqGSM49BAMDMGExCzAJBgNVBAYT +AkpQMSUwIwYDVQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMSswKQYD +VQQDEyJTZWN1cml0eSBDb21tdW5pY2F0aW9uIEVDQyBSb290Q0ExMB4XDTE2MDYx +NjA1MTUyOFoXDTM4MDExODA1MTUyOFowYTELMAkGA1UEBhMCSlAxJTAjBgNVBAoT +HFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xKzApBgNVBAMTIlNlY3VyaXR5 +IENvbW11bmljYXRpb24gRUNDIFJvb3RDQTEwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AASkpW9gAwPDvTH00xecK4R1rOX9PVdu12O/5gSJko6BnOPpR27KkBLIE+Cnnfdl +dB9sELLo5OnvbYUymUSxXv3MdhDYW72ixvnWQuRXdtyQwjWpS4g8EkdtXP9JTxpK +ULGjQjBAMB0GA1UdDgQWBBSGHOf+LaVKiwj+KBH6vqNm+GBZLzAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjAVXUI9/Lbu +9zuxNuie9sRGKEkz0FhDKmMpzE2xtHqiuQ04pV1IKv3LsnNdo4gIxwwCMQDAqy0O +be0YottT6SXbVQjgUMzfRGEWgqtJsLKB7HOHeLRMsmIbEvoWTSVLY70eN9k= +-----END CERTIFICATE-----`)) + + // OU=Security Communication RootCA2; O=SECOM Trust Systems CO.,LTD.; C=JP + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDEl +MCMGA1UEChMcU0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMe +U2VjdXJpdHkgQ29tbXVuaWNhdGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoX +DTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMCSlAxJTAjBgNVBAoTHFNFQ09NIFRy +dXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3VyaXR5IENvbW11bmlj +YXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAV +OVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGr +zbl+dp+++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVM +VAX3NuRFg3sUZdbcDE3R3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQ +hNBqyjoGADdH5H5XTz+L62e4iKrFvlNVspHEfbmwhRkGeC7bYRr6hfVKkaHnFtWO +ojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1KEOtOghY6rCcMU/Gt1SSw +awNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8QIH4D5cs +OPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpF +coJxDjrSzG+ntKEju/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXc +okgfGT+Ok+vx+hfuzU7jBBJV1uXk3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8 +t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6qtnRGEmyR7jTV7JqR50S+kDFy +1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29mvVXIwAHIRc/ +SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 +-----END CERTIFICATE-----`)) + + // CN=Entrust Root Certification Authority; OU=www.entrust.net/CPS is incorporated by reference, (c) 2006 Entrust, Inc.; O=Entrust, Inc.; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMC +VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0 +Lm5ldC9DUFMgaXMgaW5jb3Jwb3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMW +KGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsGA1UEAxMkRW50cnVzdCBSb290IENl +cnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0MloXDTI2MTEyNzIw +NTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMTkw +NwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSBy +ZWZlcmVuY2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNV +BAMTJEVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBALaVtkNC+sZtKm9I35RMOVcF7sN5EUFo +Nu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYszA9u3g3s+IIRe7bJWKKf4 +4LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOwwCj0Yzfv9 +KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGI +rb68j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi +94DkZfs0Nw4pgHBNrziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOB +sDCBrTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAi +gA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1MzQyWjAfBgNVHSMEGDAWgBRo +kORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DHhmak8fdLQ/uE +vW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA +A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9t +O1KzKtvn1ISMY/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6Zua +AGAT/3B+XxFNSRuzFVJ7yVTav52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP +9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTSW3iDVuycNsMm4hH2Z0kdkquM++v/ +eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0tHuu2guQOHXvgR1m +0vdXcDazv/wor3ElhVsT/h5/WrQ8 +-----END CERTIFICATE-----`)) + + // CN=Sectigo Public Server Authentication Root E46; O=Sectigo Limited; C=GB + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQsw +CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T +ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcN +MjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYG +A1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccC +WvkEN/U0NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+ +6xnOQ6OjQjBAMB0GA1UdDgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8B +Af8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNnADBkAjAn7qRa +qCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RHlAFWovgzJQxC36oCMB3q +4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21USAGKcw== +-----END CERTIFICATE-----`)) + + // CN=COMODO ECC Certification Authority; O=COMODO CA Limited; L=Salford; ST=Greater Manchester; C=GB + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTEL +MAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE +BxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMT +IkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwMzA2MDAw +MDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdy +ZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09N +T0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSR +FtSrYpn1PlILBs5BAH+X4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0J +cfRK9ChQtP6IHG4/bC8vCVlbpVsLM5niwz2J+Wos77LTBumjQjBAMB0GA1UdDgQW +BBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VGFAkK+qDm +fQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdv +GDeAU/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= +-----END CERTIFICATE-----`)) + + // CN=COMODO Certification Authority; O=COMODO CA Limited; L=Salford; ST=Greater Manchester; C=GB + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIID0DCCArigAwIBAgIQIKTEf93f4cdTYwcTiHdgEjANBgkqhkiG9w0BAQUFADCB +gTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNV +BAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xMTAxMDEwMDAw +MDBaFw0zMDEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3Jl +YXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01P +RE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3 +UcEbVASY06m/weaKXTuH+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI +2GqGd0S7WWaXUF601CxwRM/aN5VCaTwwxHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8 +Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV4EajcNxo2f8ESIl33rXp ++2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA1KGzqSX+ +DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5O +nKVIrLsm9wIDAQABo0IwQDAdBgNVHQ4EFgQUC1jli8ZMFTekQKkwqSG+RzZaVv8w +DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD +ggEBAC/JxBwHO89hAgCx2SFRdXIDMLDEFh9sAIsQrK/xR9SuEDwMGvjUk2ysEDd8 +t6aDZK3N3w6HM503sMZ7OHKx8xoOo/lVem0DZgMXlUrxsXrfViEGQo+x06iF3u6X +HWLrp+cxEmbDD6ZLLkGC9/3JG6gbr+48zuOcrigHoSybJMIPIyaDMouGDx8rEkYl +Fo92kANr3ryqImhrjKGsKxE5pttwwn1y6TPn/CbxdFqR5p2ErPioBhlG5qfpqjQi +pKGfeq23sqSaM4hxAjwu1nqyH6LKwN0vEJT9s4yEIHlG1QXUEOTS22RPuFvuG8Ug +R1uUq27UlTMdphVx8fiUylQ5PsE= +-----END CERTIFICATE-----`)) + + // CN=COMODO RSA Certification Authority; O=COMODO CA Limited; L=Salford; ST=Greater Manchester; C=GB + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCB +hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV +BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMTE5 +MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgT +EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR +Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR +6FSS0gpWsawNJN3Fz0RndJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8X +pz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZFGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC +9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+5eNu/Nio5JIk2kNrYrhV +/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pGx8cgoLEf +Zd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z ++pUX2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7w +qP/0uK3pN/u6uPQLOvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZah +SL0896+1DSJMwBGB7FY79tOi4lu3sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVIC +u9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+CGCe01a60y1Dma/RMhnEw6abf +Fobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5WdYgGq/yapiq +crxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E +FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB +/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvl +wFTPoCWOAvn9sKIN9SCYPBMtrFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM +4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+nq6PK7o9mfjYcwlYRm6mnPTXJ9OV +2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSgtZx8jb8uk2Intzna +FxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwWsRqZ +CuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiK +boHGhfKppC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmcke +jkk9u+UJueBPSZI9FoJAzMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yL +S0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHqZJx64SIDqZxubw5lT2yHh17zbqD5daWb +QOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk527RH89elWsn2/x20Kk4yl +0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7ILaZRfyHB +NVOFBkpdn627G190 +-----END CERTIFICATE-----`)) + + // CN=USERTrust RSA Certification Authority; O=The USERTRUST Network; L=Jersey City; ST=New Jersey; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB +iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl +cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV +BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw +MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV +BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B +3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY +tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/ +Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2 +VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT +79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6 +c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT +Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l +c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee +UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE +Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G +A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF +Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO +VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3 +ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs +8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR +iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze +Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ +XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/ +qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB +VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB +L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG +jjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE-----`)) + + // CN=USERTrust ECC Certification Authority; O=The USERTRUST Network; L=Jersey City; ST=New Jersey; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL +MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl +eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT +JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx +MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT +Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg +VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm +aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo +I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng +o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G +A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB +zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW +RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= +-----END CERTIFICATE-----`)) + + // CN=Sectigo Public Server Authentication Root R46; O=Sectigo Limited; C=GB + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa +ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz +SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf +iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X +ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 +IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS +VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE +SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu ++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt +8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L +HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt +zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P +AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ +YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 +gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA +Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB +JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX +DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui +TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 +dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 +LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp +0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY +QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE-----`)) + + // CN=Entrust Root Certification Authority - G2; OU=See www.entrust.net/legal-terms, (c) 2009 Entrust, Inc. - for authorized use only; O=Entrust, Inc.; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMC +VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50 +cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3Qs +IEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVz +dCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwHhcNMDkwNzA3MTcy +NTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVu +dHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwt +dGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0 +aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmlj +YXRpb24gQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP/vaCeb9zYQYKpSfYs1/T +RU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXzHHfV1IWN +cCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hW +wcKUs/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1 +U1+cPvQXLOZprE4yTGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0 +jaWvYkxN4FisZDQSA/i2jZRjJKRxAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ60B7vfec7aVHUbI2fkBJmqzAN +BgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5ZiXMRrEPR9RP/ +jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ +Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v +1fN2D807iDginWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4R +nAuknZoh8/CbCzB428Hch0P+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmH +VHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xOe4pIb4tF9g== +-----END CERTIFICATE-----`)) + + // CN=Entrust Root Certification Authority - EC1; OU=See www.entrust.net/legal-terms, (c) 2012 Entrust, Inc. - for authorized use only; O=Entrust, Inc.; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkG +A1UEBhMCVVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3 +d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVu +dHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEzMDEGA1UEAxMq +RW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRUMxMB4XDTEy +MTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYwFAYD +VQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0 +L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0g +Zm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBD +ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEVDMTB2MBAGByqGSM49AgEGBSuBBAAi +A2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHyAsWfoPZb1YsGGYZPUxBt +ByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef9eNi1KlH +Bz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVC +R98crlOZF7ZvHH3hvxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nX +hTcGtXsI/esni0qU+eH6p44mCOh8kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G +-----END CERTIFICATE-----`)) + + // CN=SSL.com Root Certification Authority RSA; O=SSL Corporation; L=Houston; ST=Texas; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UE +BhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQK +DA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTcz +OTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv +dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv +bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2R +xFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aX +qhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcC +C52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+lSgQ3 +6YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh +/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrF +YD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93E +JNyAKoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVc +US4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8 +ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm ++Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqi +M+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4G +A1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGV +cpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBc +Hadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgs +PgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/ +q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0 +cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jr +a6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90I +H37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/Y +K9f1JmzJBjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtu +nLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406ywKBjYZC6VWg3dGq2ktuf +oYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShY +Ic2wBlX7Jz9TkHCpBB5XJ7k= +-----END CERTIFICATE-----`)) + + // CN=SSL.com TLS ECC Root CA 2022; O=SSL Corporation; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICOjCCAcCgAwIBAgIQFAP1q/s3ixdAW+JDsqXRxDAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxT +U0wuY29tIFRMUyBFQ0MgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzM0OFoXDTQ2 +MDgxOTE2MzM0N1owTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh +dGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgRUNDIFJvb3QgQ0EgMjAyMjB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABEUpNXP6wrgjzhR9qLFNoFs27iosU8NgCTWyJGYm +acCzldZdkkAZDsalE3D07xJRKF3nzL35PIXBz5SQySvOkkJYWWf9lCcQZIxPBLFN +SeR7T5v15wj4A4j3p8OSSxlUgaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME +GDAWgBSJjy+j6CugFFR781a4Jl9nOAuc0DAdBgNVHQ4EFgQUiY8vo+groBRUe/NW +uCZfZzgLnNAwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMFXjIlbp +15IkWE8elDIPDAI2wv2sdDJO4fscgIijzPvX6yv/N33w7deedWo1dlJF4AIxAMeN +b0Igj762TVntd00pxCAgRWSGOlDGxK0tk/UYfXLtqc/ErFc2KAhl3zx5Zn6g6g== +-----END CERTIFICATE-----`)) + + // CN=SSL.com TLS RSA Root CA 2022; O=SSL Corporation; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIQb77arXO9CEDii02+1PdbkTANBgkqhkiG9w0BAQsFADBO +MQswCQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQD +DBxTU0wuY29tIFRMUyBSU0EgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzQyMloX +DTQ2MDgxOTE2MzQyMVowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jw +b3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgUlNBIFJvb3QgQ0EgMjAyMjCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANCkCXJPQIgSYT41I57u9nTP +L3tYPc48DRAokC+X94xI2KDYJbFMsBFMF3NQ0CJKY7uB0ylu1bUJPiYYf7ISf5OY +t6/wNr/y7hienDtSxUcZXXTzZGbVXcdotL8bHAajvI9AI7YexoS9UcQbOcGV0ins +S657Lb85/bRi3pZ7QcacoOAGcvvwB5cJOYF0r/c0WRFXCsJbwST0MXMwgsadugL3 +PnxEX4MN8/HdIGkWCVDi1FW24IBydm5MR7d1VVm0U3TZlMZBrViKMWYPHqIbKUBO +L9975hYsLfy/7PO0+r4Y9ptJ1O4Fbtk085zx7AGL0SDGD6C1vBdOSHtRwvzpXGk3 +R2azaPgVKPC506QVzFpPulJwoxJF3ca6TvvC0PeoUidtbnm1jPx7jMEWTO6Af77w +dr5BUxIzrlo4QqvXDz5BjXYHMtWrifZOZ9mxQnUjbvPNQrL8VfVThxc7wDNY8VLS ++YCk8OjwO4s4zKTGkH8PnP2L0aPP2oOnaclQNtVcBdIKQXTbYxE3waWglksejBYS +d66UNHsef8JmAOSqg+qKkK3ONkRN0VHpvB/zagX9wHQfJRlAUW7qglFA35u5CCoG +AtUjHBPW6dvbxrB6y3snm/vg1UYk7RBLY0ulBY+6uB0rpvqR4pJSvezrZ5dtmi2f +gTIFZzL7SAg/2SW4BCUvAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0j +BBgwFoAU+y437uOEeicuzRk1sTN8/9REQrkwHQYDVR0OBBYEFPsuN+7jhHonLs0Z +NbEzfP/UREK5MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAjYlt +hEUY8U+zoO9opMAdrDC8Z2awms22qyIZZtM7QbUQnRC6cm4pJCAcAZli05bg4vsM +QtfhWsSWTVTNj8pDU/0quOr4ZcoBwq1gaAafORpR2eCNJvkLTqVTJXojpBzOCBvf +R4iyrT7gJ4eLSYwfqUdYe5byiB0YrrPRpgqU+tvT5TgKa3kSM/tKWTcWQA673vWJ +DPFs0/dRa1419dvAJuoSc06pkZCmF8NsLzjUo3KUQyxi4U5cMj29TH0ZR6LDSeeW +P4+a0zvkEdiLA9z2tmBVGKaBUfPhqBVq6+AL8BQx1rmMRTqoENjwuSfr98t67wVy +lrXEj5ZzxOhWc5y8aVFjvO9nHEMaX3cZHxj4HCUp+UmZKbaSPaKDN7EgkaibMOlq +bLQjk2UEqxHzDh1TJElTHaE/nUiSEeJ9DU/1172iWD54nR4fK/4huxoTtrEoZP2w +AgDHbICivRZQIA9ygV/MlP+7mea6kMvq+cYMwq7FGc4zoWtcu358NFcXrfA/rs3q +r5nsLFR+jM4uElZI7xc7P0peYNLcdDa8pUNjyw9bowJWCZ4kLOGGgYz+qxcs+sji +Mho6/4UIyYOf8kpIEFR3N+2ivEC+5BB09+Rbu7nzifmPQdjH5FCQNYA+HLhNkNPU +98OwoX6EyneSMSy4kLGCenROmxMmtNVQZlR4rmA= +-----END CERTIFICATE-----`)) + + // CN=SSL.com Root Certification Authority ECC; O=SSL Corporation; L=Houston; ST=Texas; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMC +VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T +U0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNDAzWhcNNDEwMjEyMTgxNDAz +WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0 +b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBS +b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI +7Z4INcgn64mMU1jrYor+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPg +CemB+vNH06NjMGEwHQYDVR0OBBYEFILRhXMw5zUE044CkvvlpNHEIejNMA8GA1Ud +EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTTjgKS++Wk0cQh6M0wDgYD +VR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCWe+0F+S8T +kdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+ +gA0z5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl +-----END CERTIFICATE-----`)) + + // CN=SSL.com EV Root Certification Authority RSA R2; O=SSL Corporation; L=Houston; ST=Texas; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNV +BAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UE +CgwPU1NMIENvcnBvcmF0aW9uMTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMB4XDTE3MDUzMTE4MTQzN1oXDTQy +MDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4G +A1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYDVQQD +DC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvq +M0fNTPl9fb69LT3w23jhhqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssuf +OePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7wcXHswxzpY6IXFJ3vG2fThVUCAtZJycxa +4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTOZw+oz12WGQvE43LrrdF9 +HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+B6KjBSYR +aZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcA +b9ZhCBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQ +Gp8hLH94t2S42Oim9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQV +PWKchjgGAGYS5Fl2WlPAApiiECtoRHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMO +pgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+SlmJuwgUHfbSguPvuUCYHBBXtSu +UDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48+qvWBkofZ6aY +MBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV +HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa4 +9QaAJadz20ZpqJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBW +s47LCp1Jjr+kxJG7ZhcFUZh1++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5 +Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nxY/hoLVUE0fKNsKTPvDxeH3jnpaAg +cLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2GguDKBAdRUNf/ktUM +79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDzOFSz +/bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXt +ll9ldDz7CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEm +Kf7GUmG6sXP/wwyc5WxqlD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKK +QbNmC1r7fSOl8hqw/96bg5Qu0T/fkreRrwU7ZcegbLHNYhLDkBvjJc40vG93drEQ +w/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1hlMYegouCRw2n5H9gooi +S9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX9hwJ1C07 +mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w== +-----END CERTIFICATE-----`)) + + // CN=SSL.com EV Root Certification Authority ECC; O=SSL Corporation; L=Houston; ST=Texas; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMC +VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T +U0wgQ29ycG9yYXRpb24xNDAyBgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNTIzWhcNNDEwMjEyMTgx +NTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv +dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NMLmNv +bSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMA +VIbc/R/fALhBYlzccBYy3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1Kthku +WnBaBu2+8KGwytAJKaNjMGEwHQYDVR0OBBYEFFvKXuXe0oGqzagtZFG22XKbl+ZP +MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe5d7SgarNqC1kUbbZcpuX +5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJN+vp1RPZ +ytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZg +h5Mmm7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg== +-----END CERTIFICATE-----`)) + + // CN=SwissSign Gold CA - G2; O=SwissSign AG; C=CH + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2ln +biBHb2xkIENBIC0gRzIwHhcNMDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBF +MQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dpc3NTaWduIEFHMR8wHQYDVQQDExZT +d2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUqt2/8 +76LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+ +bbqBHH5CjCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c +6bM8K8vzARO/Ws/BtQpgvd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqE +emA8atufK+ze3gE/bk3lUIbLtK/tREDFylqM2tIrfKjuvqblCqoOpd8FUrdVxyJd +MmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvRAiTysybUa9oEVeXBCsdt +MDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuendjIj3o02y +MszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69y +FGkOpeUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPi +aG59je883WX0XaxR7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxM +gI93e2CaHt+28kgeDrpOVG2Y4OGiGqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCB +qTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUWyV7 +lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64OfPAeGZe6Drn +8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov +L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe6 +45R88a7A3hfm5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczO +UYrHUDFu4Up+GC9pWbY9ZIEr44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5 +O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOfMke6UiI0HTJ6CVanfCU2qT1L2sCC +bwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6mGu6uLftIdxf+u+yv +GPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxpmo/a +77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCC +hdiDyyJkvC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid3 +92qgQmwLOM7XdVAyksLfKzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEpp +Ld6leNcG2mqeSz53OiATIgHQv2ieY2BrNU0LbbqhPcCT4H8js1WtciVORvnSFu+w +ZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6LqjviOvrv1vA+ACOzB2+htt +Qc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ +-----END CERTIFICATE-----`)) + + // CN=TWCA CYBER Root CA; OU=Root CA; O=TAIWAN-CA; C=TW + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIQQAE0jMIAAAAAAAAAATzyxjANBgkqhkiG9w0BAQwFADBQ +MQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290 +IENBMRswGQYDVQQDExJUV0NBIENZQkVSIFJvb3QgQ0EwHhcNMjIxMTIyMDY1NDI5 +WhcNNDcxMTIyMTU1OTU5WjBQMQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FO +LUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NBIENZQkVSIFJvb3Qg +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDG+Moe2Qkgfh1sTs6P +40czRJzHyWmqOlt47nDSkvgEs1JSHWdyKKHfi12VCv7qze33Kc7wb3+szT3vsxxF +avcokPFhV8UMxKNQXd7UtcsZyoC5dc4pztKFIuwCY8xEMCDa6pFbVuYdHNWdZsc/ +34bKS1PE2Y2yHer43CdTo0fhYcx9tbD47nORxc5zb87uEB8aBs/pJ2DFTxnk684i +JkXXYJndzk834H/nY62wuFm40AZoNWDTNq5xQwTxaWV4fPMf88oon1oglWa0zbfu +j3ikRRjpJi+NmykosaS3Om251Bw4ckVYsV7r8Cibt4LK/c/WMw+f+5eesRycnupf +Xtuq3VTpMCEobY5583WSjCb+3MX2w7DfRFlDo7YDKPYIMKoNM+HvnKkHIuNZW0CP +2oi3aQiotyMuRAlZN1vH4xfyIutuOVLF3lSnmMlLIJXcRolftBL5hSmO68gnFSDA +S9TMfAxsNAwmmyYxpjyn9tnQS6Jk/zuZQXLB4HCX8SS7K8R0IrGsayIyJNN4KsDA +oS/xUgXJP+92ZuJF2A09rZXIx4kmyA+upwMu+8Ff+iDhcK2wZSA3M2Cw1a/XDBzC +kHDXShi8fgGwsOsVHkQGzaRP6AzRwyAQ4VRlnrZR0Bp2a0JaWHY06rc3Ga4udfmW +5cFZ95RXKSWNOkyrTZpB0F8mAwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSdhWEUfMFib5do5E83QOGt4A1WNzAd +BgNVHQ4EFgQUnYVhFHzBYm+XaORPN0DhreANVjcwDQYJKoZIhvcNAQEMBQADggIB +AGSPesRiDrWIzLjHhg6hShbNcAu3p4ULs3a2D6f/CIsLJc+o1IN1KriWiLb73y0t +tGlTITVX1olNc79pj3CjYcya2x6a4CD4bLubIp1dhDGaLIrdaqHXKGnK/nZVekZn +68xDiBaiA9a5F/gZbG0jAn/xX9AKKSM70aoK7akXJlQKTcKlTfjF/biBzysseKNn +TKkHmvPfXvt89YnNdJdhEGoHK4Fa0o635yDRIG4kqIQnoVesqlVYL9zZyvpoBJ7t +RCT5dEA7IzOrg1oYJkK2bVS1FmAwbLGg+LhBoF1JSdJlBTrq/p1hvIbZv97Tujqx +f36SNI7JAG7cmL3c7IAFrQI932XtCwP39xaEBDG6k5TY8hL4iuO/Qq+n1M0RFxbI +Qh0UqEL20kCGoE8jypZFVmAGzbdVAaYBlGX+bgUJurSkquLvWL69J1bY73NxW0Qz +8ppy6rBePm6pUlvscG21h483XjyMnM7k8M4MZ0HMzvaAq07MTFb1wWFZk7Q+ptq4 +NxKfKjLji7gh7MMrZQzvIt6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzX +xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6 +t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X +-----END CERTIFICATE-----`)) + + // CN=TWCA Global Root CA; OU=Root CA; O=TAIWAN-CA; C=TW + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcx +EjAQBgNVBAoTCVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMT +VFdDQSBHbG9iYWwgUm9vdCBDQTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5 +NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQKEwlUQUlXQU4tQ0ExEDAOBgNVBAsT +B1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3QgQ0EwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2CnJfF +10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz +0ALfUPZVr2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfCh +MBwqoJimFb3u/Rk28OKRQ4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbH +zIh1HrtsBv+baz4X7GGqcXzGHaL3SekVtTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc +46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1WKKD+u4ZqyPpcC1jcxkt2 +yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99sy2sbZCi +laLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYP +oA/pyJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQA +BDzfuBSO6N+pjWxnkjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcE +qYSjMq+u7msXi7Kx/mzhkIyIqJdIzshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm +4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6gcFGn90xHNcgL +1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn +LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WF +H6vPNOw/KP4M8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNo +RI2T9GRwoD2dKAXDOXC4Ynsg/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+ +nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlglPx4mI88k1HtQJAH32RjJMtOcQWh +15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryPA9gK8kxkRr05YuWW +6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3mi4TW +nsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5j +wa19hAM8EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWz +aGHQRiapIVJpLesux+t3zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmy +KwbQBM0= +-----END CERTIFICATE-----`)) + + // CN=TeliaSonera Root CA v1; O=TeliaSonera + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAw +NzEUMBIGA1UECgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJv +b3QgQ0EgdjEwHhcNMDcxMDE4MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYD +VQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwWVGVsaWFTb25lcmEgUm9vdCBDQSB2 +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+6yfwIaPzaSZVfp3F +VRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA3GV1 +7CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+X +Z75Ljo1kB1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+ +/jXh7VB7qTCNGdMJjmhnXb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs +81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxHoLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkm +dtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3F0fUTPHSiXk+TT2YqGHe +Oh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJoWjiUIMu +sDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4 +pgd7gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fs +slESl1MpWtTwEhDcTwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQ +arMCpgKIv7NHfirZ1fpoeDVNAgMBAAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYD +VR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qWDNXr+nuqF+gTEjANBgkqhkiG +9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNmzqjMDfz1mgbl +dxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx +0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1Tj +TQpgcmLNkQfWpb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBed +Y2gea+zDTYa4EzAvXUYNR0PVG6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7 +Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpcc41teyWRyu5FrgZLAMzTsVlQ2jqI +OylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOTJsjrDNYmiLbAJM+7 +vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2qReW +t88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcn +HL/EVlP6Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVx +SK236thZiNSQvxaz2emsWWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= +-----END CERTIFICATE-----`)) + + // CN=Telia Root CA v2; O=Telia Finland Oyj; C=FI + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIPAWdfJ9b+euPkrL4JWwWeMA0GCSqGSIb3DQEBCwUAMEQx +CzAJBgNVBAYTAkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UE +AwwQVGVsaWEgUm9vdCBDQSB2MjAeFw0xODExMjkxMTU1NTRaFw00MzExMjkxMTU1 +NTRaMEQxCzAJBgNVBAYTAkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZ +MBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2MjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBALLQPwe84nvQa5n44ndp586dpAO8gm2h/oFlH0wnrI4AuhZ76zBq +AMCzdGh+sq/H1WKzej9Qyow2RCRj0jbpDIX2Q3bVTKFgcmfiKDOlyzG4OiIjNLh9 +vVYiQJ3q9HsDrWj8soFPmNB06o3lfc1jw6P23pLCWBnglrvFxKk9pXSW/q/5iaq9 +lRdU2HhE8Qx3FZLgmEKnpNaqIJLNwaCzlrI6hEKNfdWV5Nbb6WLEWLN5xYzTNTOD +n3WhUidhOPFZPY5Q4L15POdslv5e2QJltI5c0BE0312/UqeBAMN/mUWZFdUXyApT +7GPzmX3MaRKGwhfwAZ6/hLzRUssbkmbOpFPlob/E2wnW5olWK8jjfN7j/4nlNW4o +6GwLI1GpJQXrSPjdscr6bAhR77cYbETKJuFzxokGgeWKrLDiKca5JLNrRBH0pUPC +TEPlcDaMtjNXepUugqD0XBCzYYP2AgWGLnwtbNwDRm41k9V6lS/eINhbfpSQBGq6 +WT0EBXWdN6IOLj3rwaRSg/7Qa9RmjtzG6RJOHSpXqhC8fF6CfaamyfItufUXJ63R +DolUK5X6wK0dmBR4M0KGCqlztft0DbcbMBnEWg4cJ7faGND/isgFuvGqHKI3t+ZI +pEYslOqodmJHixBTB0hXbOKSTbauBcvcwUpej6w9GU7C7WB1K9vBykLVAgMBAAGj +YzBhMB8GA1UdIwQYMBaAFHKs5DN5qkWH9v2sHZ7Wxy+G2CQ5MB0GA1UdDgQWBBRy +rOQzeapFh/b9rB2e1scvhtgkOTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw +AwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAoDtZpwmUPjaE0n4vOaWWl/oRrfxn83EJ +8rKJhGdEr7nv7ZbsnGTbMjBvZ5qsfl+yqwE2foH65IRe0qw24GtixX1LDoJt0nZi +0f6X+J8wfBj5tFJ3gh1229MdqfDBmgC9bXXYfef6xzijnHDoRnkDry5023X4blMM +A8iZGok1GTzTyVR8qPAs5m4HeW9q4ebqkYJpCh3DflminmtGFZhb069GHWLIzoBS +SRE/yQQSwxN8PzuKlts8oB4KtItUsiRnDe+Cy748fdHif64W1lZYudogsYMVoe+K +TTJvQS8TUoKU1xrBeKJR3Stwbbca+few4GeXVtt8YVMJAygCQMez2P2ccGrGKMOF +6eLtGpOg3kuYooQ+BXcBlj37tCAPnHICehIv1aO6UXivKitEZU61/Qrowc15h2Er +3oBXRb9n8ZuRXqWk7FlIEA04x7D6w0RtBPV4UBySllva9bguulvP5fBqnUsvWHMt +Ty3EHD70sz+rFQ47GUGKpMFXEmZxTPpT41frYpUJnlTd0cI8Vzy9OK2YZLe4A5pT +VmBds9hCG1xLEooc6+t9xnppxyd/pPiL8uSUZodL6ZQHCRJ5irLrdATczvREWeAW +ysUsWNc8e89ihmpQfTU2Zqf7N+cox9jQraVplI/owd8k+BsHMYeB2F326CjYSlKA +rBPuUBQemMc= +-----END CERTIFICATE-----`)) + + // CN=Trustwave Global ECC P384 Certification Authority; O=Trustwave Holdings, Inc.; L=Chicago; ST=Illinois; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAf +BgNVBAoTGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3 +YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0x +NzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYDVQQGEwJVUzERMA8G +A1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0 +d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBF +Q0MgUDM4NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuB +BAAiA2IABGvaDXU1CDFHBa5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJ +j9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr/TklZvFe/oyujUF5nQlgziip04pt89ZF +1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHQ8BAf8EBQMDBwYAMB0G +A1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNnADBkAjA3 +AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsC +MGclCrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVu +Sw== +-----END CERTIFICATE-----`)) + + // CN=Trustwave Global ECC P256 Certification Authority; O=Trustwave Holdings, Inc.; L=Chicago; ST=Illinois; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAf +BgNVBAoTGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3 +YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0x +NzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYDVQQGEwJVUzERMA8G +A1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0 +d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBF +Q0MgUDI1NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABH77bOYj43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoN +FWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqmP62jQzBBMA8GA1UdEwEB/wQFMAMBAf8w +DwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt0UrrdaVKEJmzsaGLSvcw +CgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjzRM4q3wgh +DDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7 +-----END CERTIFICATE-----`)) + + // CN=SecureTrust CA; O=SecureTrust Corporation; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI +MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x +FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz +MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv +cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz +Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO +0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao +wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj +7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS +8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT +BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg +JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3 +6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/ +3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm +D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS +CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR +3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= +-----END CERTIFICATE-----`)) + + // CN=Trustwave Global Certification Authority; O=Trustwave Holdings, Inc.; L=Chicago; ST=Illinois; C=US + chromeIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQsw +CQYDVQQGEwJVUzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28x +ITAfBgNVBAoMGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1 +c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMx +OTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJVUzERMA8GA1UECAwI +SWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2ZSBI +b2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +ALldUShLPDeS0YLOvR29zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0Xzn +swuvCAAJWX/NKSqIk4cXGIDtiLK0thAfLdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu +7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4BqstTnoApTAbqOl5F2brz8 +1Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9oWN0EACyW +80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotP +JqX+OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1l +RtzuzWniTY+HKE40Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfw +hI0Vcnyh78zyiGG69Gm7DIwLdVcEuE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10 +coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm+9jaJXLE9gCxInm943xZYkqc +BW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqjifLJS3tBEW1n +twiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1Ud +DwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W +0OhUKDtkLSGm+J1WE2pIPU/HPinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfe +uyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0HZJDmHvUqoai7PF35owgLEQzxPy0Q +lG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla4gt5kNdXElE1GYhB +aCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5RvbbE +sLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPT +MaCm/zjdzyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qe +qu5AvzSxnI9O4fKSTx+O856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxh +VicGaeVyQYHTtgGJoC86cnn+OjC/QezHYj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8 +h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu3R3y4G5OBVixwJAWKqQ9 +EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP29FpHOTK +yeC2nOnOcXHebD8WpHk= +-----END CERTIFICATE-----`)) +} diff --git a/common/certificate/mozilla.go b/common/certificate/mozilla.go new file mode 100644 index 00000000..a5db7267 --- /dev/null +++ b/common/certificate/mozilla.go @@ -0,0 +1,4593 @@ +// Code generated by 'make update_certificates'. DO NOT EDIT. + +package certificate + +import "crypto/x509" + +var mozillaIncluded *x509.CertPool + +func init() { + mozillaIncluded = x509.NewCertPool() + + // Actalis Authentication Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UE +BhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8w +MzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 +IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDkyMjExMjIwMlowazELMAkGA1UEBhMC +SVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1 +ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENB +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNv +UTufClrJwkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX +4ay8IMKx4INRimlNAJZaby/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9 +KK3giq0itFZljoZUj5NDKd45RnijMCO6zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/ +gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1fYVEiVRvjRuPjPdA1Yprb +rxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2oxgkg4YQ +51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2F +be8lEfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxe +KF+w6D9Fz8+vm2/7hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4F +v6MGn8i1zeQf1xcGDXqVdFUNaBr8EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbn +fpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5jF66CyCU3nuDuP/jVo23Eek7 +jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLYiDrIn3hm7Ynz +ezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt +ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQAL +e3KHwGCmSUyIWOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70 +jsNjLiNmsGe+b7bAEzlgqqI0JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDz +WochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKxK3JCaKygvU5a2hi/a5iB0P2avl4V +SM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+Xlff1ANATIGk0k9j +pwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC4yyX +X04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+Ok +fcvHlXHo2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7R +K4X9p2jIugErsWx0Hbhzlefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btU +ZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXemOR/qnuOf0GZvBeyqdn6/axag67XH/JJU +LysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9vwGYT7JZVEc+NHt4bVaT +LnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== +-----END CERTIFICATE-----`)) + + // TunTrust Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIUEwLV4kBMkkaGFmddtLu7sms+/BMwDQYJKoZIhvcNAQEL +BQAwYTELMAkGA1UEBhMCVE4xNzA1BgNVBAoMLkFnZW5jZSBOYXRpb25hbGUgZGUg +Q2VydGlmaWNhdGlvbiBFbGVjdHJvbmlxdWUxGTAXBgNVBAMMEFR1blRydXN0IFJv +b3QgQ0EwHhcNMTkwNDI2MDg1NzU2WhcNNDQwNDI2MDg1NzU2WjBhMQswCQYDVQQG +EwJUTjE3MDUGA1UECgwuQWdlbmNlIE5hdGlvbmFsZSBkZSBDZXJ0aWZpY2F0aW9u +IEVsZWN0cm9uaXF1ZTEZMBcGA1UEAwwQVHVuVHJ1c3QgUm9vdCBDQTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAMPN0/y9BFPdDCA61YguBUtB9YOCfvdZ +n56eY+hz2vYGqU8ftPkLHzmMmiDQfgbU7DTZhrx1W4eI8NLZ1KMKsmwb60ksPqxd +2JQDoOw05TDENX37Jk0bbjBU2PWARZw5rZzJJQRNmpA+TkBuimvNKWfGzC3gdOgF +VwpIUPp6Q9p+7FuaDmJ2/uqdHYVy7BG7NegfJ7/Boce7SBbdVtfMTqDhuazb1YMZ +GoXRlJfXyqNlC/M4+QKu3fZnz8k/9YosRxqZbwUN/dAdgjH8KcwAWJeRTIAAHDOF +li/LQcKLEITDCSSJH7UP2dl3RxiSlGBcx5kDPP73lad9UKGAwqmDrViWVSHbhlnU +r8a83YFuB9tgYv7sEG7aaAH0gxupPqJbI9dkxt/con3YS7qC0lH4Zr8GRuR5KiY2 +eY8fTpkdso8MDhz/yV3A/ZAQprE38806JG60hZC/gLkMjNWb1sjxVj8agIl6qeIb +MlEsPvLfe/ZdeikZjuXIvTZxi11Mwh0/rViizz1wTaZQmCXcI/m4WEEIcb9PuISg +jwBUFfyRbVinljvrS5YnzWuioYasDXxU5mZMZl+QviGaAkYt5IPCgLnPSz7ofzwB +7I9ezX/SKEIBlYrilz0QIX32nRzFNKHsLA4KUiwSVXAkPcvCFDVDXSdOvsC9qnyW +5/yeYa1E0wCXAgMBAAGjYzBhMB0GA1UdDgQWBBQGmpsfU33x9aTI04Y+oXNZtPdE +ITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFAaamx9TffH1pMjThj6hc1m0 +90QhMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAqgVutt0Vyb+z +xiD2BkewhpMl0425yAA/l/VSJ4hxyXT968pk21vvHl26v9Hr7lxpuhbI87mP0zYu +QEkHDVneixCwSQXi/5E/S7fdAo74gShczNxtr18UnH1YeA32gAm56Q6XKRm4t+v4 +FstVEuTGfbvE7Pi1HE4+Z7/FXxttbUcoqgRYYdZ2vyJ/0Adqp2RT8JeNnYA/u8EH +22Wv5psymsNUk8QcCMNE+3tjEUPRahphanltkE8pjkcFwRJpadbGNjHh/PqAulxP +xOu3Mqz4dWEX1xAZufHSCe96Qp1bWgvUxpVOKs7/B9dPfhgGiPEZtdmYu65xxBzn +dFlY7wyJz4sfdZMaBBSSSFCp61cpABbjNhzI+L/wM9VBD8TMPN3pM0MBkRArHtG5 +Xc0yGYuPjCB31yLEQtyEFpslbei0VXF/sHyz03FJuc9SpAQ/3D2gu68zngowYI7b +nV2UqL1g52KAdoGDDIzMMEZJ4gzSqK/rYXHv5yJiqfdcZGyfFoxnNidF9Ql7v/YQ +CvGwjVRDjAS6oz/v4jXH+XTgbzRB0L9zZVcg+ZtnemZoJE6AZb0QmQZZ8mWvuMZH +u/2QeItBcy6vVR/cO5JyboTT0GFMDcx2V+IthSIVNg3rAZ3r2OvEhJn7wAzMMujj +d9qDRIueVSjAi1jTkD5OGwDxFa2DK5o= +-----END CERTIFICATE-----`)) + + // Amazon Root CA 1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA +A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI +U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs +N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv +o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU +5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy +rqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE-----`)) + + // Amazon Root CA 2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK +gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ +W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg +1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K +8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r +2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me +z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR +8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj +mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz +7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 ++XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI +0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm +UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 +LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY ++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS +k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl +7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm +btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl +urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ +fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 +n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE +76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H +9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT +4PsJYGw= +-----END CERTIFICATE-----`)) + + // Amazon Root CA 3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g +Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG +A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg +Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl +ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr +ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr +BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM +YyRIHN8wfdVoOw== +-----END CERTIFICATE-----`)) + + // Amazon Root CA 4 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g +Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG +A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg +Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi +9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk +M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB +MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw +CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW +1KyLa2tJElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE-----`)) + + // Starfield Services Root Certificate Authority - G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT +HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs +ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 +MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD +VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy +ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy +dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p +OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2 +8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K +Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe +hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk +6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw +DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q +AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI +bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB +ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z +qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd +iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn +0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN +sSi6 +-----END CERTIFICATE-----`)) + + // Certum CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDDDCCAfSgAwIBAgIDAQAgMA0GCSqGSIb3DQEBBQUAMD4xCzAJBgNVBAYTAlBM +MRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBD +QTAeFw0wMjA2MTExMDQ2MzlaFw0yNzA2MTExMDQ2MzlaMD4xCzAJBgNVBAYTAlBM +MRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBD +QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM6xwS7TT3zNJc4YPk/E +jG+AanPIW1H4m9LcuwBcsaD8dQPugfCI7iNS6eYVM42sLQnFdvkrOYCJ5JdLkKWo +ePhzQ3ukYbDYWMzhbGZ+nPMJXlVjhNWo7/OxLjBos8Q82KxujZlakE403Daaj4GI +ULdtlkIJ89eVgw1BS7Bqa/j8D35in2fE7SZfECYPCE/wpFcozo+47UX2bu4lXapu +Ob7kky/ZR6By6/qmW6/KUz/iDsaWVhFu9+lmqSbYf5VT7QqFiLpPKaVCjF62/IUg +AKpoC6EahQGcxEZjgoi2IrHu/qpGWX7PNSzVttpd90gzFFS269lvzs2I1qsb2pY7 +HVkCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEA +uI3O7+cUus/usESSbLQ5PqKEbq24IXfS1HeCh+YgQYHu4vgRt2PRFze+GXYkHAQa +TOs9qmdvLdTN/mUxcMUbpgIKumB7bVjCmkn+YzILa+M6wKyrO7Do0wlRjBCDxjTg +xSvgGrZgFCdsMneMvLJymM/NzD+5yCRCFNZX/OYmQ6kd5YCQzgNUKD73P9P4Te1q +CjqTE5s7FCMTY5w/0YcneeVMUeMBrYVdGjux1XMQpNPyvG5k9VpWkKjHDkx0Dy5x +O/fIR/RpbxXyEV6DHpx8Uq79AtoSqFlnGNu8cN2bsWntgM6JQEhqDjXKKWYVIZQs +6GAqm4VKQPNriiTsBhYscw== +-----END CERTIFICATE-----`)) + + // Certum EC-384 CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICZTCCAeugAwIBAgIQeI8nXIESUiClBNAt3bpz9DAKBggqhkjOPQQDAzB0MQsw +CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScw +JQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGTAXBgNVBAMT +EENlcnR1bSBFQy0zODQgQ0EwHhcNMTgwMzI2MDcyNDU0WhcNNDMwMzI2MDcyNDU0 +WjB0MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBT +LkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGTAX +BgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATE +KI6rGFtqvm5kN2PkzeyrOvfMobgOgknXhimfoZTy42B4mIF4Bk3y7JoOV2CDn7Tm +Fy8as10CW4kjPMIRBSqniBMY81CE1700LCeJVf/OTOffph8oxPBUw7l8t1Ot68Kj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI0GZnQkdjrzife81r1HfS+8 +EF9LMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjADVS2m5hjEfO/J +UG7BJw+ch69u1RsIGL2SKcHvlJF40jocVYli5RsJHrpka/F2tNQCMQC0QoSZ/6vn +nvuRlydd3LBbMHHOXjgaatkl5+r3YZJW+OraNsKHZZYuciUvf9/DE8k= +-----END CERTIFICATE-----`)) + + // Certum Trusted Network CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBM +MSIwIAYDVQQKExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5D +ZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBU +cnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIyMTIwNzM3WhcNMjkxMjMxMTIwNzM3 +WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMg +Uy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSIw +IAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rH +UV+rpDKmYYe2bg+G0jACl/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LM +TXPb865Px1bVWqeWifrzq2jUI4ZZJ88JJ7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVU +BBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4fOQtf/WsX+sWn7Et0brM +kUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0cvW0QM8x +AcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNV +HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15y +sHhE49wcrwn9I0j6vSrEuVUEtRCjjSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfL +I9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1mS1FhIrlQgnXdAIv94nYmem8 +J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5ajZt3hrvJBW8qY +VoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI +03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= +-----END CERTIFICATE-----`)) + + // Certum Trusted Network CA 2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCB +gDELMAkGA1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMu +QS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIG +A1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMCIYDzIwMTExMDA2MDgz +OTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UEChMZ +VW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3 +b3JrIENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWA +DGSdhhuWZGc/IjoedQF97/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn +0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+oCgCXhVqqndwpyeI1B+twTUrWwbNWuKFB +OJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40bRr5HMNUuctHFY9rnY3lE +fktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2puTRZCr+E +Sv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1m +o130GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02i +sx7QBlrd9pPPV3WZ9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOW +OZV7bIBaTxNyxtd9KXpEulKkKtVBRgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgez +Tv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pyehizKV/Ma5ciSixqClnrDvFAS +adgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vMBhBgu4M1t15n +3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQ +F/xlhMcQSZDe28cmk4gmb3DWAl45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTf +CVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuAL55MYIR4PSFk1vtBHxgP58l1cb29 +XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMoclm2q8KMZiYcdywm +djWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tMpkT/ +WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jb +AoJnwTnbw3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksq +P/ujmv5zMnHCnsZy4YpoJ/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Ko +b7a6bINDd82Kkhehnlt4Fj1F4jNy3eFmypnTycUm/Q1oBEauttmbjL4ZvrHG8hnj +XALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLXis7VmFxWlgPF7ncGNf/P +5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7zAYspsbi +DrW5viSP +-----END CERTIFICATE-----`)) + + // Certum Trusted Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFwDCCA6igAwIBAgIQHr9ZULjJgDdMBvfrVU+17TANBgkqhkiG9w0BAQ0FADB6 +MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu +MScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxHzAdBgNV +BAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwHhcNMTgwMzE2MTIxMDEzWhcNNDMw +MzE2MTIxMDEzWjB6MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEg +U3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRo +b3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQDRLY67tzbqbTeRn06TpwXkKQMlzhyC93yZ +n0EGze2jusDbCSzBfN8pfktlL5On1AFrAygYo9idBcEq2EXxkd7fO9CAAozPOA/q +p1x4EaTByIVcJdPTsuclzxFUl6s1wB52HO8AU5853BSlLCIls3Jy/I2z5T4IHhQq +NwuIPMqw9MjCoa68wb4pZ1Xi/K1ZXP69VyywkI3C7Te2fJmItdUDmj0VDT06qKhF +8JVOJVkdzZhpu9PMMsmN74H+rX2Ju7pgE8pllWeg8xn2A1bUatMn4qGtg/BKEiJ3 +HAVz4hlxQsDsdUaakFjgao4rpUYwBI4Zshfjvqm6f1bxJAPXsiEodg42MEx51UGa +mqi4NboMOvJEGyCI98Ul1z3G4z5D3Yf+xOr1Uz5MZf87Sst4WmsXXw3Hw09Omiqi +7VdNIuJGmj8PkTQkfVXjjJU30xrwCSss0smNtA0Aq2cpKNgB9RkEth2+dv5yXMSF +ytKAQd8FqKPVhJBPC/PgP5sZ0jeJP/J7UhyM9uH3PAeXjA6iWYEMspA90+NZRu0P +qafegGtaqge2Gcu8V/OXIXoMsSt0Puvap2ctTMSYnjYJdmZm/Bo/6khUHL4wvYBQ +v3y1zgD2DGHZ5yQD4OMBgQ692IU0iL2yNqh7XAjlRICMb/gv1SHKHRzQ+8S1h9E6 +Tsd2tTVItQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSM+xx1 +vALTn04uSNn5YFSqxLNP+jAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQAD +ggIBAEii1QALLtA/vBzVtVRJHlpr9OTy4EA34MwUe7nJ+jW1dReTagVphZzNTxl4 +WxmB82M+w85bj/UvXgF2Ez8sALnNllI5SW0ETsXpD4YN4fqzX4IS8TrOZgYkNCvo +zMrnadyHncI013nR03e4qllY/p0m+jiGPp2Kh2RX5Rc64vmNueMzeMGQ2Ljdt4NR +5MTMI9UGfOZR0800McD2RrsLrfw9EAUqO0qRJe6M1ISHgCq8CYyqOhNf6DR5UMEQ +GfnTKB7U0VEwKbOukGfWHwpjscWpxkIxYxeU72nLL/qMFH3EQxiJ2fAyQOaA4kZf +5ePBAFmo+eggvIksDkc0C+pXwlM2/KfUrzHN/gLldfq5Jwn58/U7yn2fqSLLiMmq +0Uc9NneoWWRrJ8/vJ8HjJLWG965+Mk2weWjROeiQWMODvA8s1pfrzgzhIMfatz7D +P78v3DSk+yshzWePS/Tj6tQ/50+6uaWTRRxmHyH6ZF5v4HaUMst19W7l9o/HuKTM +qJZ9ZPskWkoDbGs4xugDQ5r3V7mzKWmTOPQD8rv7gmsHINFSH5pkAnuYZttcTVoP +0ISVoDwUQwbKytu4QTbaakRnh6+v40URFWkIsr4WOZckbxJF0WddCajJFdr60qZf +E2Efv4WstK2tBZQIgx51F9NxO5NQI1mg7TyRVJ12AMXDuDjb +-----END CERTIFICATE-----`)) + + // Autoridad de Certificacion Firmaprofesional CIF A62634068 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGFDCCA/ygAwIBAgIIG3Dp0v+ubHEwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UE +BhMCRVMxQjBABgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1h +cHJvZmVzaW9uYWwgQ0lGIEE2MjYzNDA2ODAeFw0xNDA5MjMxNTIyMDdaFw0zNjA1 +MDUxNTIyMDdaMFExCzAJBgNVBAYTAkVTMUIwQAYDVQQDDDlBdXRvcmlkYWQgZGUg +Q2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBBNjI2MzQwNjgwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDDUtd9 +thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQM +cas9UX4PB99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefG +L9ItWY16Ck6WaVICqjaY7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15i +NA9wBj4gGFrO93IbJWyTdBSTo3OxDqqHECNZXyAFGUftaI6SEspd/NYrspI8IM/h +X68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyIplD9amML9ZMWGxmPsu2b +m8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctXMbScyJCy +Z/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirja +EbsXLZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/T +KI8xWVvTyQKmtFLKbpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF +6NkBiDkal4ZkQdU7hwxu+g/GvUgUvzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVh +OSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMB0GA1UdDgQWBBRlzeurNR4APn7VdMAc +tHNHDhpkLzASBgNVHRMBAf8ECDAGAQH/AgEBMIGmBgNVHSAEgZ4wgZswgZgGBFUd +IAAwgY8wLwYIKwYBBQUHAgEWI2h0dHA6Ly93d3cuZmlybWFwcm9mZXNpb25hbC5j +b20vY3BzMFwGCCsGAQUFBwICMFAeTgBQAGEAcwBlAG8AIABkAGUAIABsAGEAIABC +AG8AbgBhAG4AbwB2AGEAIAA0ADcAIABCAGEAcgBjAGUAbABvAG4AYQAgADAAOAAw +ADEANzAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAHSHKAIrdx9m +iWTtj3QuRhy7qPj4Cx2Dtjqn6EWKB7fgPiDL4QjbEwj4KKE1soCzC1HA01aajTNF +Sa9J8OA9B3pFE1r/yJfY0xgsfZb43aJlQ3CTkBW6kN/oGbDbLIpgD7dvlAceHabJ +hfa9NPhAeGIQcDq+fUs5gakQ1JZBu/hfHAsdCPKxsIl68veg4MSPi3i1O1ilI45P +Vf42O+AMt8oqMEEgtIDNrvx2ZnOorm7hfNoD6JQg5iKj0B+QXSBTFCZX2lSX3xZE +EAEeiGaPcjiT3SC3NL7X8e5jjkd5KAb881lFJWAiMxujX6i6KtoaPc1A6ozuBRWV +1aUsIC+nmCjuRfzxuIgALI9C2lHVnOUTaHFFQ4ueCyE8S1wF3BqfmI7avSKecs2t +CsvMo2ebKHTEm9caPARYpoKdrcd7b/+Alun4jWq9GJAd/0kakFI3ky88Al2CdgtR +5xbHV/g4+afNmyJU72OwFW1TZQNKXkqgsqeOSQBZONXH9IBk9W6VULgRfhVwOEqw +f9DEMnDAGf/JOC0ULGb0QkTmVXYbgBVX/8Cnp6o5qtjTcNAuuuuUavpfNIbnYrX9 +ivAwhZTJryQCL2/W3Wf+47BVTwSYT6RBVuKT0Gro1vP7ZeDOdcQxWQzugsgMYDNK +GbqEZycPvEJdvSRUDewdcAZfpLz6IHxV +-----END CERTIFICATE-----`)) + + // FIRMAPROFESIONAL CA ROOT-A WEB + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICejCCAgCgAwIBAgIQMZch7a+JQn81QYehZ1ZMbTAKBggqhkjOPQQDAzBuMQsw +CQYDVQQGEwJFUzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25hbCBTQTEYMBYGA1UE +YQwPVkFURVMtQTYyNjM0MDY4MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFMIENB +IFJPT1QtQSBXRUIwHhcNMjIwNDA2MDkwMTM2WhcNNDcwMzMxMDkwMTM2WjBuMQsw +CQYDVQQGEwJFUzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25hbCBTQTEYMBYGA1UE +YQwPVkFURVMtQTYyNjM0MDY4MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFMIENB +IFJPT1QtQSBXRUIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARHU+osEaR3xyrq89Zf +e9MEkVz6iMYiuYMQYneEMy3pA4jU4DP37XcsSmDq5G+tbbT4TIqk5B/K6k84Si6C +cyvHZpsKjECcfIr28jlgst7L7Ljkb+qbXbdTkBgyVcUgt5SjYzBhMA8GA1UdEwEB +/wQFMAMBAf8wHwYDVR0jBBgwFoAUk+FDY1w8ndYn81LsF7Kpryz3dvgwHQYDVR0O +BBYEFJPhQ2NcPJ3WJ/NS7Beyqa8s93b4MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjO +PQQDAwNoADBlAjAdfKR7w4l1M+E7qUW/Runpod3JIha3RxEL2Jq68cgLcFBTApFw +hVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dG +XSaQpYXFuXqUPoeovQA= +-----END CERTIFICATE-----`)) + + // ANF Secure Server Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF7zCCA9egAwIBAgIIDdPjvGz5a7EwDQYJKoZIhvcNAQELBQAwgYQxEjAQBgNV +BAUTCUc2MzI4NzUxMDELMAkGA1UEBhMCRVMxJzAlBgNVBAoTHkFORiBBdXRvcmlk +YWQgZGUgQ2VydGlmaWNhY2lvbjEUMBIGA1UECxMLQU5GIENBIFJhaXoxIjAgBgNV +BAMTGUFORiBTZWN1cmUgU2VydmVyIFJvb3QgQ0EwHhcNMTkwOTA0MTAwMDM4WhcN +MzkwODMwMTAwMDM4WjCBhDESMBAGA1UEBRMJRzYzMjg3NTEwMQswCQYDVQQGEwJF +UzEnMCUGA1UEChMeQU5GIEF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uMRQwEgYD +VQQLEwtBTkYgQ0EgUmFpejEiMCAGA1UEAxMZQU5GIFNlY3VyZSBTZXJ2ZXIgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANvrayvmZFSVgpCj +cqQZAZ2cC4Ffc0m6p6zzBE57lgvsEeBbphzOG9INgxwruJ4dfkUyYA8H6XdYfp9q +yGFOtibBTI3/TO80sh9l2Ll49a2pcbnvT1gdpd50IJeh7WhM3pIXS7yr/2WanvtH +2Vdy8wmhrnZEE26cLUQ5vPnHO6RYPUG9tMJJo8gN0pcvB2VSAKduyK9o7PQUlrZX +H1bDOZ8rbeTzPvY1ZNoMHKGESy9LS+IsJJ1tk0DrtSOOMspvRdOoiXsezx76W0OL +zc2oD2rKDF65nkeP8Nm2CgtYZRczuSPkdxl9y0oukntPLxB3sY0vaJxizOBQ+OyR +p1RMVwnVdmPF6GUe7m1qzwmd+nxPrWAI/VaZDxUse6mAq4xhj0oHdkLePfTdsiQz +W7i1o0TJrH93PB0j7IKppuLIBkwC/qxcmZkLLxCKpvR/1Yd0DVlJRfbwcVw5Kda/ +SiOL9V8BY9KHcyi1Swr1+KuCLH5zJTIdC2MKF4EA/7Z2Xue0sUDKIbvVgFHlSFJn +LNJhiQcND85Cd8BEc5xEUKDbEAotlRyBr+Qc5RQe8TZBAQIvfXOn3kLMTOmJDVb3 +n5HUA8ZsyY/b2BzgQJhdZpmYgG4t/wHFzstGH6wCxkPmrqKEPMVOHj1tyRRM4y5B +u8o5vzY8KhmqQYdOpc5LMnndkEl/AgMBAAGjYzBhMB8GA1UdIwQYMBaAFJxf0Gxj +o1+TypOYCK2Mh6UsXME3MB0GA1UdDgQWBBScX9BsY6Nfk8qTmAitjIelLFzBNzAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AgEATh65isagmD9uw2nAalxJUqzLK114OMHVVISfk/CHGT0sZonrDUL8zPB1hT+L +9IBdeeUXZ701guLyPI59WzbLWoAAKfLOKyzxj6ptBZNscsdW699QIyjlRRA96Gej +rw5VD5AJYu9LWaL2U/HANeQvwSS9eS9OICI7/RogsKQOLHDtdD+4E5UGUcjohybK +pFtqFiGS3XNgnhAY3jyB6ugYw3yJ8otQPr0R4hUDqDZ9MwFsSBXXiJCZBMXM5gf0 +vPSQ7RPi6ovDj6MzD8EpTBNO2hVWcXNyglD2mjN8orGoGjR0ZVzO0eurU+AagNjq +OknkJjCb5RyKqKkVMoaZkgoQI1YS4PbOTOK7vtuNknMBZi9iPrJyJ0U27U1W45eZ +/zo1PqVUSlJZS2Db7v54EX9K3BR5YLZrZAPbFYPhor72I5dQ8AkzNqdxliXzuUJ9 +2zg/LFis6ELhDtjTO0wugumDLmsx2d1Hhk9tl5EuT+IocTUW0fJz/iUrB0ckYyfI ++PbZa/wSMVYIwFNCr5zQM378BvAxRAMU8Vjq8moNqRGyg77FGr8H6lnco4g175x2 +MjxNBiLOFeXdntiP2t7SxDnlF4HPOEfrf4htWRvfn0IUrn7PqLBmZdo3r5+qPeoo +tt7VMVgWglvquxl1AnMaykgaIZOQCo6ThKd9OyMYkomgjaw= +-----END CERTIFICATE-----`)) + + // Buypass Class 2 Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEd +MBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3Mg +Q2xhc3MgMiBSb290IENBMB4XDTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1ow +TjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAw +HgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1g1Lr +6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPV +L4O2fuPn9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC91 +1K2GScuVr1QGbNgGE41b/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHx +MlAQTn/0hpPshNOOvEu/XAFOBz3cFIqUCqTqc/sLUegTBxj6DvEr0VQVfTzh97QZ +QmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeffawrbD02TTqigzXsu8lkB +arcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgIzRFo1clr +Us3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLi +FRhnBkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRS +P/TizPJhk9H9Z2vXUq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN +9SG9dKpN6nIDSdvHXx1iY8f93ZHsM+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxP +AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMmAd+BikoL1Rpzz +uvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAU18h +9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s +A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3t +OluwlN5E40EIosHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo ++fsicdl9sz1Gv7SEr5AcD48Saq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7 +KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYdDnkM/crqJIByw5c/8nerQyIKx+u2 +DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWDLfJ6v9r9jv6ly0Us +H8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0oyLQ +I+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK7 +5t98biGCwWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h +3PFaTWwyI0PurKju7koSCTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPz +Y11aWOIv4x3kqdbQCtCev9eBCfHJxyYNrJgWVqA= +-----END CERTIFICATE-----`)) + + // Buypass Class 3 Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEd +MBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3Mg +Q2xhc3MgMyBSb290IENBMB4XDTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFow +TjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAw +HgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRHsJ8Y +ZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3E +N3coTRiR5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9 +tznDDgFHmV0ST9tD+leh7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX +0DJq1l1sDPGzbjniazEuOQAnFN44wOwZZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c +/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH2xc519woe2v1n/MuwU8X +KhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV/afmiSTY +zIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvS +O1UQRwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D +34xFMFbG02SrZvPAXpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgP +K9Dx2hzLabjKSWJtyNBjYt1gD1iqj6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3 +AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEe4zf/lb+74suwv +Tg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAACAj +QTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV +cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXS +IGrs/CIBKM+GuIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2 +HJLw5QY33KbmkJs4j1xrG0aGQ0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsa +O5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8ZORK15FTAaggiG6cX0S5y2CBNOxv +033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2KSb12tjE8nVhz36u +dmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz6MkE +kbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg41 +3OEMXbugUZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvD +u79leNKGef9JOxqDDPDeeOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq +4/g7u9xN12TyUb7mqqta6THuBrxzvxNiCp/HuZc= +-----END CERTIFICATE-----`)) + + // Certainly Root E1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB9zCCAX2gAwIBAgIQBiUzsUcDMydc+Y2aub/M+DAKBggqhkjOPQQDAzA9MQsw +CQYDVQQGEwJVUzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0YWlu +bHkgUm9vdCBFMTAeFw0yMTA0MDEwMDAwMDBaFw00NjA0MDEwMDAwMDBaMD0xCzAJ +BgNVBAYTAlVTMRIwEAYDVQQKEwlDZXJ0YWlubHkxGjAYBgNVBAMTEUNlcnRhaW5s +eSBSb290IEUxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE3m/4fxzf7flHh4axpMCK ++IKXgOqPyEpeKn2IaKcBYhSRJHpcnqMXfYqGITQYUBsQ3tA3SybHGWCA6TS9YBk2 +QNYphwk8kXr2vBMj3VlOBF7PyAIcGFPBMdjaIOlEjeR2o0IwQDAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ygYy2R17ikq6+2uI1g4 +hevIIgcwCgYIKoZIzj0EAwMDaAAwZQIxALGOWiDDshliTd6wT99u0nCK8Z9+aozm +ut6Dacpps6kFtZaSF4fC0urQe87YQVt8rgIwRt7qy12a7DLCZRawTDBcMPPaTnOG +BtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR +-----END CERTIFICATE-----`)) + + // Certainly Root R1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIRAI4P+UuQcWhlM1T01EQ5t+AwDQYJKoZIhvcNAQELBQAw +PTELMAkGA1UEBhMCVVMxEjAQBgNVBAoTCUNlcnRhaW5seTEaMBgGA1UEAxMRQ2Vy +dGFpbmx5IFJvb3QgUjEwHhcNMjEwNDAxMDAwMDAwWhcNNDYwNDAxMDAwMDAwWjA9 +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0 +YWlubHkgUm9vdCBSMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANA2 +1B/q3avk0bbm+yLA3RMNansiExyXPGhjZjKcA7WNpIGD2ngwEc/csiu+kr+O5MQT +vqRoTNoCaBZ0vrLdBORrKt03H2As2/X3oXyVtwxwhi7xOu9S98zTm/mLvg7fMbed +aFySpvXl8wo0tf97ouSHocavFwDvA5HtqRxOcT3Si2yJ9HiG5mpJoM610rCrm/b0 +1C7jcvk2xusVtyWMOvwlDbMicyF0yEqWYZL1LwsYpfSt4u5BvQF5+paMjRcCMLT5 +r3gajLQ2EBAHBXDQ9DGQilHFhiZ5shGIXsXwClTNSaa/ApzSRKft43jvRl5tcdF5 +cBxGX1HpyTfcX35pe0HfNEXgO4T0oYoKNp43zGJS4YkNKPl6I7ENPT2a/Z2B7yyQ +wHtETrtJ4A5KVpK8y7XdeReJkd5hiXSSqOMyhb5OhaRLWcsrxXiOcVTQAjeZjOVJ +6uBUcqQRBi8LjMFbvrWhsFNunLhgkR9Za/kt9JQKl7XsxXYDVBtlUrpMklZRNaBA +2CnbrlJ2Oy0wQJuK0EJWtLeIAaSHO1OWzaMWj/Nmqhexx2DgwUMFDO6bW2BvBlyH +Wyf5QBGenDPBt+U1VwV/J84XIIwc/PH72jEpSe31C4SnT8H2TsIonPru4K8H+zMR +eiFPCyEQtkA6qyI6BJyLm4SGcprSp6XEtHWRqSsjAgMBAAGjQjBAMA4GA1UdDwEB +/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTgqj8ljZ9EXME66C6u +d0yEPmcM9DANBgkqhkiG9w0BAQsFAAOCAgEAuVevuBLaV4OPaAszHQNTVfSVcOQr +PbA56/qJYv331hgELyE03fFo8NWWWt7CgKPBjcZq91l3rhVkz1t5BXdm6ozTaw3d +8VkswTOlMIAVRQdFGjEitpIAq5lNOo93r6kiyi9jyhXWx8bwPWz8HA2YEGGeEaIi +1wrykXprOQ4vMMM2SZ/g6Q8CRFA3lFV96p/2O7qUpUzpvD5RtOjKkjZUbVwlKNrd +rRT90+7iIgXr0PK3aBLXWopBGsaSpVo7Y0VPv+E6dyIvXL9G+VoDhRNCX8reU9di +taY1BMJH/5n9hN9czulegChB8n3nHpDYT3Y+gjwN/KUD+nsa2UUeYNrEjvn8K8l7 +lcUq/6qJ34IxD3L/DCfXCh5WAFAeDJDBlrXYFIW7pw0WwfgHJBu6haEaBQmAupVj +yTrsJZ9/nbqkRxWbRHDxakvWOF5D8xh+UG7pWijmZeZ3Gzr9Hb4DJqPb1OG7fpYn +Kx3upPvaJVQTA945xsMfTZDsjxtK0hzthZU4UHlG1sGQUDGpXJpuHfUzVounmdLy +yCwzk5Iwx06MZTMQZBf9JBeW0Y3COmor6xOLRPIh80oat3df1+2IpHLlOR+Vnb5n +wXARPbv0+Em34yaXOp/SX3z7wJl8OSngex2/DaeP0ik0biQVy96QXr8axGbqwua6 +OV+KmalBWQewLK8= +-----END CERTIFICATE-----`)) + + // Certigna + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNV +BAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4X +DTA3MDYyOTE1MTMwNVoXDTI3MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQ +BgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwIQ2VydGlnbmEwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7qXOEm7RFHYeGifBZ4 +QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyHGxny +gQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbw +zBfsV1/pogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q +130yGLMLLGq/jj8UEYkgDncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2 +JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKfIrjxwo1p3Po6WAbfAgMBAAGjgbwwgbkw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQtCRZvgHyUtVF9lo53BEw +ZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJBgNVBAYT +AkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzj +AQ/JSP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG +9w0BAQUFAAOCAQEAhQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8h +bV6lUmPOEvjvKtpv6zf+EwLHyzs+ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFnc +fca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1kluPBS1xp81HlDQwY9qcEQCYsuu +HWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY1gkIl2PlwS6w +t0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw +WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== +-----END CERTIFICATE-----`)) + + // Certigna Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAw +WjELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAw +MiA0ODE0NjMwODEwMDAzNjEZMBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0x +MzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjdaMFoxCzAJBgNVBAYTAkZSMRIwEAYD +VQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYzMDgxMDAwMzYxGTAX +BgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw +ggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sO +ty3tRQgXstmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9M +CiBtnyN6tMbaLOQdLNyzKNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPu +I9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8JXrJhFwLrN1CTivngqIkicuQstDuI7pm +TLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16XdG+RCYyKfHx9WzMfgIh +C59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq4NYKpkDf +ePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3Yz +IoejwpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWT +Co/1VTp2lc5ZmIoJlXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1k +JWumIWmbat10TWuXekG9qxf5kBdIjzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5 +hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp//TBt2dzhauH8XwIDAQABo4IB +GjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of +1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczov +L3d3d3cuY2VydGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilo +dHRwOi8vY3JsLmNlcnRpZ25hLmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYr +aHR0cDovL2NybC5kaGlteW90aXMuY29tL2NlcnRpZ25hcm9vdGNhLmNybDANBgkq +hkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOItOoldaDgvUSILSo3L +6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxPTGRG +HVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH6 +0BGM+RFq7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncB +lA2c5uk5jR+mUYyZDDl34bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdi +o2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1 +gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS6Cvu5zHbugRqh5jnxV/v +faci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaYtlu3zM63 +Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayh +jWZSaX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw +3kAP+HwV96LOPNdeE4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0= +-----END CERTIFICATE-----`)) + + // certSIGN ROOT CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYT +AlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBD +QTAeFw0wNjA3MDQxNzIwMDRaFw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJP +MREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7IJUqOtdu0KBuqV5Do +0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHHrfAQ +UySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5d +RdY4zTW2ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQ +OA7+j0xbm0bqQfWwCHTD0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwv +JoIQ4uNllAoEwF73XVv4EOLQunpL+943AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08C +AwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAcYwHQYDVR0O +BBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IBAQA+0hyJ +LjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecY +MnQ8SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ +44gx+FkagQnIl6Z0x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6I +Jd1hJyMctTEHBDa0GpC9oHRxUIltvBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNw +i/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7NzTogVZ96edhBiIL5VaZVDADlN +9u6wWk5JRFRYX0KD +-----END CERTIFICATE-----`)) + + // certSIGN ROOT CA G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNV +BAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04g +Uk9PVCBDQSBHMjAeFw0xNzAyMDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJ +BgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJ +R04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMDF +dRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05N0Iw +vlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZ +uIt4ImfkabBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhp +n+Sc8CnTXPnGFiWeI8MgwT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKs +cpc/I1mbySKEwQdPzH/iV8oScLumZfNpdWO9lfsbl83kqK/20U6o2YpxJM02PbyW +xPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91QqhngLjYl/rNUssuHLoPj1P +rCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732jcZZroiF +DsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fx +DTvf95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgy +LcsUDFDYg2WD7rlcz8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6C +eWRgKRM+o/1Pcmqr4tTluCRVLERLiohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB +/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSCIS1mxteg4BXrzkwJ +d8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOBywaK8SJJ6ejq +kX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC +b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQl +qiCA2ClV9+BB/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0 +OJD7uNGzcgbJceaBxXntC6Z58hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+c +NywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5BiKDUyUM/FHE5r7iOZULJK2v0ZXk +ltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklWatKcsWMy5WHgUyIO +pwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tUSxfj +03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZk +PuXaTH4MNMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE +1LlSVHJ7liXMvGnjSG4N0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MX +QRBdJ3NghVdJIgc= +-----END CERTIFICATE-----`)) + + // ePKI Root Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBe +MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0 +ZC4xKjAoBgNVBAsMIWVQS0kgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe +Fw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMxMjdaMF4xCzAJBgNVBAYTAlRXMSMw +IQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEqMCgGA1UECwwhZVBL +SSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAH +SyZbCUNsIZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAh +ijHyl3SJCRImHJ7K2RKilTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3X +DZoTM1PRYfl61dd4s5oz9wCGzh1NlDivqOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1 +TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX12ruOzjjK9SXDrkb5wdJ +fzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0OWQqraffA +sgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uU +WH1+ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLS +nT0IFaUQAS2zMnaolQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pH +dmX2Os+PYhcZewoozRrSgx4hxyy/vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJip +NiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXiZo1jDiVN1Rmy5nk3pyKdVDEC +AwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/QkqiMAwGA1UdEwQF +MAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH +ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGB +uvl2ICO1J2B01GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6Yl +PwZpVnPDimZI+ymBV3QGypzqKOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkP +JXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdVxrsStZf0X4OFunHB2WyBEXYKCrC/ +gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEPNXubrjlpC2JgQCA2 +j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+rGNm6 +5ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUB +o2M3IUxExJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS +/jQ6fbjpKdx2qcgw+BRxgMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2z +Gp1iro2C6pSe3VkQw63d4k3jMdXH7OjysP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTE +W9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmODBCEIZ43ygknQW/2xzQ+D +hNQ+IIX3Sj0rnP0qCglN6oH4EZw= +-----END CERTIFICATE-----`)) + + // HiPKI Root CA - G1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFajCCA1KgAwIBAgIQLd2szmKXlKFD6LDNdmpeYDANBgkqhkiG9w0BAQsFADBP +MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0 +ZC4xGzAZBgNVBAMMEkhpUEtJIFJvb3QgQ0EgLSBHMTAeFw0xOTAyMjIwOTQ2MDRa +Fw0zNzEyMzExNTU5NTlaME8xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3 +YSBUZWxlY29tIENvLiwgTHRkLjEbMBkGA1UEAwwSSGlQS0kgUm9vdCBDQSAtIEcx +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9B5/UnMyDHPkvRN0o9Qw +qNCuS9i233VHZvR85zkEHmpwINJaR3JnVfSl6J3VHiGh8Ge6zCFovkRTv4354twv +Vcg3Px+kwJyz5HdcoEb+d/oaoDjq7Zpy3iu9lFc6uux55199QmQ5eiY29yTw1S+6 +lZgRZq2XNdZ1AYDgr/SEYYwNHl98h5ZeQa/rh+r4XfEuiAU+TCK72h8q3VJGZDnz +Qs7ZngyzsHeXZJzA9KMuH5UHsBffMNsAGJZMoYFL3QRtU6M9/Aes1MU3guvklQgZ +KILSQjqj2FPseYlgSGDIcpJQ3AOPgz+yQlda22rpEZfdhSi8MEyr48KxRURHH+CK +FgeW0iEPU8DtqX7UTuybCeyvQqww1r/REEXgphaypcXTT3OUM3ECoWqj1jOXTyFj +HluP2cFeRXF3D4FdXyGarYPM+l7WjSNfGz1BryB1ZlpK9p/7qxj3ccC2HTHsOyDr +y+K49a6SsvfhhEvyovKTmiKe0xRvNlS9H15ZFblzqMF8b3ti6RZsR1pl8w4Rm0bZ +/W3c1pzAtH2lsN0/Vm+h+fbkEkj9Bn8SV7apI09bA8PgcSojt/ewsTu8mL3WmKgM +a/aOEmem8rJY5AIJEzypuxC00jBF8ez3ABHfZfjcK0NVvxaXxA/VLGGEqnKG/uY6 +fsI/fe78LxQ+5oXdUG+3Se0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQU8ncX+l6o/vY9cdVouslGDDjYr7AwDgYDVR0PAQH/BAQDAgGGMA0GCSqG +SIb3DQEBCwUAA4ICAQBQUfB13HAE4/+qddRxosuej6ip0691x1TPOhwEmSKsxBHi +7zNKpiMdDg1H2DfHb680f0+BazVP6XKlMeJ45/dOlBhbQH3PayFUhuaVevvGyuqc +SE5XCV0vrPSltJczWNWseanMX/mF+lLFjfiRFOs6DRfQUsJ748JzjkZ4Bjgs6Fza +ZsT0pPBWGTMpWmWSBUdGSquEwx4noR8RkpkndZMPvDY7l1ePJlsMu5wP1G4wB9Tc +XzZoZjmDlicmisjEOf6aIW/Vcobpf2Lll07QJNBAsNB1CI69aO4I1258EHBGG3zg +iLKecoaZAeO/n0kZtCW+VmWuF2PlHt/o/0elv+EmBYTksMCv5wiZqAxeJoBF1Pho +L5aPruJKHJwWDBNvOIf2u8g0X5IDUXlwpt/L9ZlNec1OvFefQ05rLisY+GpzjLrF +Ne85akEez3GoorKGB1s6yeHvP2UEgEcyRHCVTjFnanRbEEV16rCf0OY1/k6fi8wr +kkVbbiVghUbN0aqwdmaTd5a+g744tiROJgvM7XpWGuDpWsZkrUx6AEhEL7lAuxM+ +vhV4nYWBSipX3tUZQ9rbyltHhoMLP7YNdnhzeSJesYAfz77RP1YQmCuVh6EfnWQU +YDksswBVLuT1sw5XxJFBAJw/6KXf6vb/yPCtbVKoF6ubYfwSUTXkJf2vqmqGOQ== +-----END CERTIFICATE-----`)) + + // SecureSign Root CA12 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQEL +BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u +LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgw +NTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD +eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS +b290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3emhF +KxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mt +p7JIKwccJ/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zd +J1M3s6oYwlkm7Fsf0uZlfO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gur +FzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBFEaCeVESE99g2zvVQR9wsMJvuwPWW0v4J +hscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1UefNzFJM3IFTQy2VYzxV4+K +h9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsF +AAOCAQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6Ld +mmQOmFxv3Y67ilQiLUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJ +mBClnW8Zt7vPemVV2zfrPIpyMpcemik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA +8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPSvWKErI4cqc1avTc7bgoitPQV +55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhgaaaI5gdka9at/ +yOPiZwud9AzqVN/Ssq+xIvEg37xEHA== +-----END CERTIFICATE-----`)) + + // SecureSign Root CA14 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFcjCCA1qgAwIBAgIUZNtaDCBO6Ncpd8hQJ6JaJ90t8sswDQYJKoZIhvcNAQEM +BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u +LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExNDAeFw0yMDA0MDgw +NzA2MTlaFw00NTA0MDgwNzA2MTlaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD +eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS +b290IENBMTQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDF0nqh1oq/ +FjHQmNE6lPxauG4iwWL3pwon71D2LrGeaBLwbCRjOfHw3xDG3rdSINVSW0KZnvOg +vlIfX8xnbacuUKLBl422+JX1sLrcneC+y9/3OPJH9aaakpUqYllQC6KxNedlsmGy +6pJxaeQp8E+BgQQ8sqVb1MWoWWd7VRxJq3qdwudzTe/NCcLEVxLbAQ4jeQkHO6Lo +/IrPj8BGJJw4J+CDnRugv3gVEOuGTgpa/d/aLIJ+7sr2KeH6caH3iGicnPCNvg9J +kdjqOvn90Ghx2+m1K06Ckm9mH+Dw3EzsytHqunQG+bOEkJTRX45zGRBdAuVwpcAQ +0BB8b8VYSbSwbprafZX1zNoCr7gsfXmPvkPx+SgojQlD+Ajda8iLLCSxjVIHvXib +y8posqTdDEx5YMaZ0ZPxMBoH064iwurO8YQJzOAUbn8/ftKChazcqRZOhaBgy/ac +18izju3Gm5h1DVXoX+WViwKkrkMpKBGk5hIwAUt1ax5mnXkvpXYvHUC0bcl9eQjs +0Wq2XSqypWa9a4X0dFbD9ed1Uigspf9mR6XU/v6eVL9lfgHWMI+lNpyiUBzuOIAB +SMbHdPTGrMNASRZhdCyvjG817XsYAFs2PJxQDcqSMxDxJklt33UkN4Ii1+iW/RVL +ApY+B3KVfqs9TC7XyvDf4Fg/LS8EmjijAQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUBpOjCl4oaTeqYR3r6/wtbyPk +86AwDQYJKoZIhvcNAQEMBQADggIBAJaAcgkGfpzMkwQWu6A6jZJOtxEaCnFxEM0E +rX+lRVAQZk5KQaID2RFPeje5S+LGjzJmdSX7684/AykmjbgWHfYfM25I5uj4V7Ib +ed87hwriZLoAymzvftAj63iP/2SbNDefNWWipAA9EiOWWF3KY4fGoweITedpdopT +zfFP7ELyk+OZpDc8h7hi2/DsHzc/N19DzFGdtfCXwreFamgLRB7lUe6TzktuhsHS +DCRZNhqfLJGP4xjblJUK7ZGqDpncllPjYYPGFrojutzdfhrGe0K22VoF3Jpf1d+4 +2kd92jjbrDnVHmtsKheMYc2xbXIBw8MgAGJoFjHVdqqGuw6qnsb58Nn4DSEC5MUo +FlkRudlpcyqSeLiSV5sI8jrlL5WwWLdrIBRtFO8KvH7YVdiI2i/6GaX7i+B/OfVy +K4XELKzvGUWSTLNhB9xNH27SgRNcmvMSZ4PPmz+Ln52kuaiWA3rF7iDeM9ovnhp6 +dB7h7sxaOgTdsxoEqBRjrLdHEoOabPXm6RUVkRqEGQ6UROcSjiVbgGcZ3GOTEAtl +Lor6CZpO2oYofaphNdgOpygau1LgePhsumywbrmHXumZNTfxPWQrqaA0k89jL9WB +365jJ6UeTo3cKXhZ+PmhIIynJkBugnLNeLLIjzwec+fBH7/PzqUqm9tEZDKgu39c +JRNItX+S +-----END CERTIFICATE-----`)) + + // SecureSign Root CA15 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICIzCCAamgAwIBAgIUFhXHw9hJp75pDIqI7fBw+d23PocwCgYIKoZIzj0EAwMw +UTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBM +dGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExNTAeFw0yMDA0MDgwODMy +NTZaFw00NTA0MDgwODMyNTZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpDeWJl +cnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBSb290 +IENBMTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQLUHSNZDKZmbPSYAi4Io5GdCx4 +wCtELW1fHcmuS1Iggz24FG1Th2CeX2yF2wYUleDHKP+dX+Sq8bOLbe1PL0vJSpSR +ZHX+AezB2Ot6lHhWGENfa4HL9rzatAy2KZMIaY+jQjBAMA8GA1UdEwEB/wQFMAMB +Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTrQciu/NWeUUj1vYv0hyCTQSvT +9DAKBggqhkjOPQQDAwNoADBlAjEA2S6Jfl5OpBEHvVnCB96rMjhTKkZEBhd6zlHp +4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJSwdLZrWeqrqgHkHZAXQ6 +bkU6iYAZezKYVWOr62Nuk22rGwlgMU4= +-----END CERTIFICATE-----`)) + + // D-TRUST BR Root CA 1 2020 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQfMmPK4TX3+oPyWWa00tNljAKBggqhkjOPQQDAzBIMQsw +CQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRS +VVNUIEJSIFJvb3QgQ0EgMSAyMDIwMB4XDTIwMDIxMTA5NDUwMFoXDTM1MDIxMTA5 +NDQ1OVowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAG +A1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDEgMjAyMDB2MBAGByqGSM49AgEGBSuB +BAAiA2IABMbLxyjR+4T1mu9CFCDhQ2tuda38KwOE1HaTJddZO0Flax7mNCq7dPYS +zuht56vkPE4/RAiLzRZxy7+SmfSk1zxQVFKQhYN4lGdnoxwJGT11NIXe7WB9xwy0 +QVK5buXuQqOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFHOREKv/ +VbNafAkl1bK6CKBrqx9tMA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6g +PKA6hjhodHRwOi8vY3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X2JyX3Jvb3Rf +Y2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVjdG9yeS5kLXRydXN0Lm5l +dC9DTj1ELVRSVVNUJTIwQlIlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxPPUQtVHJ1 +c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjO +PQQDAwNpADBmAjEAlJAtE/rhY/hhY+ithXhUkZy4kzg+GkHaQBZTQgjKL47xPoFW +wKrY7RjEsK70PvomAjEA8yjixtsrmfu3Ubgko6SUeho/5jbiA1czijDLgsfWFBHV +dWNbFJWcHwHP2NVypw87 +-----END CERTIFICATE-----`)) + + // D-TRUST BR Root CA 2 2023 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFqTCCA5GgAwIBAgIQczswBEhb2U14LnNLyaHcZjANBgkqhkiG9w0BAQ0FADBI +MQswCQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlE +LVRSVVNUIEJSIFJvb3QgQ0EgMiAyMDIzMB4XDTIzMDUwOTA4NTYzMVoXDTM4MDUw +OTA4NTYzMFowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEi +MCAGA1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDIgMjAyMzCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAK7/CVmRgApKaOYkP7in5Mg6CjoWzckjYaCTcfKr +i3OPoGdlYNJUa2NRb0kz4HIHE304zQaSBylSa053bATTlfrdTIzZXcFhfUvnKLNE +gXtRr90zsWh81k5M/itoucpmacTsXld/9w3HnDY25QdgrMBM6ghs7wZ8T1soegj8 +k12b9py0i4a6Ibn08OhZWiihNIQaJZG2tY/vsvmA+vk9PBFy2OMvhnbFeSzBqZCT +Rphny4NqoFAjpzv2gTng7fC5v2Xx2Mt6++9zA84A9H3X4F07ZrjcjrqDy4d2A/wl +2ecjbwb9Z/Pg/4S8R7+1FhhGaRTMBffb00msa8yr5LULQyReS2tNZ9/WtT5PeB+U +cSTq3nD88ZP+npNa5JRal1QMNXtfbO4AHyTsA7oC9Xb0n9Sa7YUsOCIvx9gvdhFP +/Wxc6PWOJ4d/GUohR5AdeY0cW/jPSoXk7bNbjb7EZChdQcRurDhaTyN0dKkSw/bS +uREVMweR2Ds3OmMwBtHFIjYoYiMQ4EbMl6zWK11kJNXuHA7e+whadSr2Y23OC0K+ +0bpwHJwh5Q8xaRfX/Aq03u2AnMuStIv13lmiWAmlY0cL4UEyNEHZmrHZqLAbWt4N +DfTisl01gLmB1IRpkQLLddCNxbU9CZEJjxShFHR5PtbJFR2kWVki3PaKRT08EtY+ +XTIvAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUZ5Dw1t61 +GNVGKX5cq/ieCLxklRAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRCMEAwPqA8oDqG +OGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfYnJfcm9vdF9jYV8y +XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQA097N3U9swFrktpSHxQCF16+tI +FoE9c+CeJyrrd6kTpGoKWloUMz1oH4Guaf2Mn2VsNELZLdB/eBaxOqwjMa1ef67n +riv6uvw8l5VAk1/DLQOj7aRvU9f6QA4w9QAgLABMjDu0ox+2v5Eyq6+SmNMW5tTR +VFxDWy6u71cqqLRvpO8NVhTaIasgdp4D/Ca4nj8+AybmTNudX0KEPUUDAxxZiMrc +LmEkWqTqJwtzEr5SswrPMhfiHocaFpVIbVrg0M8JkiZmkdijYQ6qgYF/6FKC0ULn +4B0Y+qSFNueG4A3rvNTJ1jxD8V1Jbn6Bm2m1iWKPiFLY1/4nwSPFyysCu7Ff/vtD +hQNGvl3GyiEm/9cCnnRK3PgTFbGBVzbLZVzRHTF36SXDw7IyN9XxmAnkbWOACKsG +koHU6XCPpz+y7YaMgmo1yEJagtFSGkUPFaUA8JR7ZSdXOUPPfH/mvTWze/EZTN46 +ls/pdu4D58JDUjxqgejBWoC9EV2Ta/vH5mQ/u2kc6d0li690yVRAysuTEwrt+2aS +Ecr1wPrYg1UDfNPFIkZ1cGt5SAYqgpq/5usWDiJFAbzdNpQ0qTUmiteXue4Icr80 +knCDgKs4qllo3UCkGJCy89UDyibK79XH4I9TjvAA46jtn/mtd+ArY0+ew+43u3gJ +hJ65bvspmZDogNOfJA== +-----END CERTIFICATE-----`)) + + // D-TRUST EV Root CA 1 2020 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQXwJB13qHfEwDo6yWjfv/0DAKBggqhkjOPQQDAzBIMQsw +CQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRS +VVNUIEVWIFJvb3QgQ0EgMSAyMDIwMB4XDTIwMDIxMTEwMDAwMFoXDTM1MDIxMTA5 +NTk1OVowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAG +A1UEAxMZRC1UUlVTVCBFViBSb290IENBIDEgMjAyMDB2MBAGByqGSM49AgEGBSuB +BAAiA2IABPEL3YZDIBnfl4XoIkqbz52Yv7QFJsnL46bSj8WeeHsxiamJrSc8ZRCC +/N/DnU7wMyPE0jL1HLDfMxddxfCxivnvubcUyilKwg+pf3VlSSowZ/Rk99Yad9rD +wpdhQntJraOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH8QARY3 +OqQo5FD4pPfsazK2/umLMA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6g +PKA6hjhodHRwOi8vY3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X2V2X3Jvb3Rf +Y2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVjdG9yeS5kLXRydXN0Lm5l +dC9DTj1ELVRSVVNUJTIwRVYlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxPPUQtVHJ1 +c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjO +PQQDAwNpADBmAjEAyjzGKnXCXnViOTYAYFqLwZOZzNnbQTs7h5kXO9XMT8oi96CA +y/m0sRtW9XLS/BnRAjEAkfcwkz8QRitxpNA7RJvAKQIFskF3UfN5Wp6OFKBOQtJb +gfM0agPnIjhQW+0ZT0MW +-----END CERTIFICATE-----`)) + + // D-TRUST EV Root CA 2 2023 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFqTCCA5GgAwIBAgIQaSYJfoBLTKCnjHhiU19abzANBgkqhkiG9w0BAQ0FADBI +MQswCQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlE +LVRSVVNUIEVWIFJvb3QgQ0EgMiAyMDIzMB4XDTIzMDUwOTA5MTAzM1oXDTM4MDUw +OTA5MTAzMlowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEi +MCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDIgMjAyMzCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBANiOo4mAC7JXUtypU0w3uX9jFxPvp1sjW2l1sJkK +F8GLxNuo4MwxusLyzV3pt/gdr2rElYfXR8mV2IIEUD2BCP/kPbOx1sWy/YgJ25yE +7CUXFId/MHibaljJtnMoPDT3mfd/06b4HEV8rSyMlD/YZxBTfiLNTiVR8CUkNRFe +EMbsh2aJgWi6zCudR3Mfvc2RpHJqnKIbGKBv7FD0fUDCqDDPvXPIEysQEx6Lmqg6 +lHPTGGkKSv/BAQP/eX+1SH977ugpbzZMlWGG2Pmic4ruri+W7mjNPU0oQvlFKzIb +RlUWaqZLKfm7lVa/Rh3sHZMdwGWyH6FDrlaeoLGPaxK3YG14C8qKXO0elg6DpkiV +jTujIcSuWMYAsoS0I6SWhjW42J7YrDRJmGOVxcttSEfi8i4YHtAxq9107PncjLgc +jmgjutDzUNzPZY9zOjLHfP7KgiJPvo5iR2blzYfi6NUPGJ/lBHJLRjwQ8kTCZFZx +TnXonMkmdMV9WdEKWw9t/p51HBjGGjp82A0EzM23RWV6sY+4roRIPrN6TagD4uJ+ +ARZZaBhDM7DS3LAaQzXupdqpRlyuhoFBAUp0JuyfBr/CBTdkdXgpaP3F9ev+R/nk +hbDhezGdpn9yo7nELC7MmVcOIQxFAZRl62UJxmMiCzNJkkg8/M3OsD6Onov4/knF +NXJHAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUqvyREBuH +kV8Wub9PS5FeAByxMoAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRCMEAwPqA8oDqG +OGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfZXZfcm9vdF9jYV8y +XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQCTy6UfmRHsmg1fLBWTxj++EI14 +QvBukEdHjqOSMo1wj/Zbjb6JzkcBahsgIIlbyIIQbODnmaprxiqgYzWRaoUlrRc4 +pZt+UPJ26oUFKidBK7GB0aL2QHWpDsvxVUjY7NHss+jOFKE17MJeNRqrphYBBo7q +3C+jisosketSjl8MmxfPy3MHGcRqwnNU73xDUmPBEcrCRbH0O1P1aa4846XerOhU +t7KR/aypH/KH5BfGSah82ApB9PI+53c0BFLd6IHyTS9URZ0V4U/M5d40VxDJI3IX +cI1QcB9WbMy5/zpaT2N6w25lBx2Eof+pDGOJbbJAiDnXH3dotfyc1dZnaVuodNv8 +ifYbMvekJKZ2t0dT741Jj6m2g1qllpBFYfXeA08mD6iL8AOWsKwV0HFaanuU5nCT +2vFp4LJiTZ6P/4mdm13NRemUAiKN4DV/6PEEeXFsVIP4M7kFMhtYVRFP0OUnR3Hs +7dpn1mKmS00PaaLJvOwiS5THaJQXfuKOKD62xur1NGyfN4gHONuGcfrNlUhDbqNP +gofXNJhuS5N5YHVpD/Aa1VP6IQzCP+k/HxiMkl14p3ZnGbuy6n/pcAlWVqOwDAst +Nl7F6cTVg8uGF5csbBNvh1qvSaYd2804BC5f4ko1Di1L+KIkBI3Y4WNeApI02phh +XBxvWHZks/wCuPWdCg== +-----END CERTIFICATE-----`)) + + // D-TRUST Root CA 3 2013 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEDjCCAvagAwIBAgIDD92sMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkRF +MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxHzAdBgNVBAMMFkQtVFJVU1QgUm9vdCBD +QSAzIDIwMTMwHhcNMTMwOTIwMDgyNTUxWhcNMjgwOTIwMDgyNTUxWjBFMQswCQYD +VQQGEwJERTEVMBMGA1UECgwMRC1UcnVzdCBHbWJIMR8wHQYDVQQDDBZELVRSVVNU +IFJvb3QgQ0EgMyAyMDEzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +xHtCkoIf7O1UmI4SwMoJ35NuOpNcG+QQd55OaYhs9uFp8vabomGxvQcgdJhl8Ywm +CM2oNcqANtFjbehEeoLDbF7eu+g20sRoNoyfMr2EIuDcwu4QRjltr5M5rofmw7wJ +ySxrZ1vZm3Z1TAvgu8XXvD558l++0ZBX+a72Zl8xv9Ntj6e6SvMjZbu376Ml1wrq +WLbviPr6ebJSWNXwrIyhUXQplapRO5AyA58ccnSQ3j3tYdLl4/1kR+W5t0qp9x+u +loYErC/jpIF3t1oW/9gPP/a3eMykr/pbPBJbqFKJcu+I89VEgYaVI5973bzZNO98 +lDyqwEHC451QGsDkGSL8swIDAQABo4IBBTCCAQEwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUP5DIfccVb/Mkj6nDL0uiDyGyL+cwDgYDVR0PAQH/BAQDAgEGMIG+ +BgNVHR8EgbYwgbMwdKByoHCGbmxkYXA6Ly9kaXJlY3RvcnkuZC10cnVzdC5uZXQv +Q049RC1UUlVTVCUyMFJvb3QlMjBDQSUyMDMlMjAyMDEzLE89RC1UcnVzdCUyMEdt +YkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRpb25saXN0MDugOaA3hjVodHRwOi8v +Y3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2FfM18yMDEzLmNybDAN +BgkqhkiG9w0BAQsFAAOCAQEADlkOWOR0SCNEzzQhtZwUGq2aS7eziG1cqRdw8Cqf +jXv5e4X6xznoEAiwNStfzwLS05zICx7uBVSuN5MECX1sj8J0vPgclL4xAUAt8yQg +t4RVLFzI9XRKEBmLo8ftNdYJSNMOwLo5qLBGArDbxohZwr78e7Erz35ih1WWzAFv +m2chlTWL+BD8cRu3SzdppjvW7IvuwbDzJcmPkn2h6sPKRL8mpXSSnON065102ctN +h9j8tGlsi6BDB2B4l+nZk3zCRrybN1Kj7Yo8E6l7U0tJmhEFLAtuVqwfLoJs4Gln +tQ5tLdnkwBXxP/oYcuEVbSdbLTAoK59ImmQrme/ydUlfXA== +-----END CERTIFICATE-----`)) + + // D-TRUST Root Class 3 CA 2 2009 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF +MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD +bGFzcyAzIENBIDIgMjAwOTAeFw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NTha +ME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMM +HkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOADER03 +UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42 +tSHKXzlABF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9R +ySPocq60vFYJfxLLHLGvKZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsM +lFqVlNpQmvH/pStmMaTJOKDfHR+4CS7zp+hnUquVH+BGPtikw8paxTGA6Eian5Rp +/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUCAwEAAaOCARowggEWMA8G +A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ4PGEMA4G +A1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVj +dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUy +MENBJTIwMiUyMDIwMDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRl +cmV2b2NhdGlvbmxpc3QwQ6BBoD+GPWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3Js +L2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAwOS5jcmwwDQYJKoZIhvcNAQEL +BQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm2H6NMLVwMeni +acfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 +o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4K +zCUqNQT4YJEVdT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8 +PIWmawomDeCTmGCufsYkl4phX5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3Y +Johw1+qRzT65ysCQblrGXnRl11z+o+I= +-----END CERTIFICATE-----`)) + + // D-TRUST Root Class 3 CA 2 EV 2009 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRF +MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBD +bGFzcyAzIENBIDIgRVYgMjAwOTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUw +NDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxELVRydXN0IEdtYkgxKjAoBgNV +BAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAwOTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfSegpn +ljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM0 +3TP1YtHhzRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6Z +qQTMFexgaDbtCHu39b+T7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lR +p75mpoo6Kr3HGrHhFPC+Oh25z1uxav60sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8 +HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure3511H3a6UCAwEAAaOCASQw +ggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyvcop9Ntea +HNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFw +Oi8vZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xh +c3MlMjAzJTIwQ0ElMjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1E +RT9jZXJ0aWZpY2F0ZXJldm9jYXRpb25saXN0MEagRKBChkBodHRwOi8vd3d3LmQt +dHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xhc3NfM19jYV8yX2V2XzIwMDku +Y3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+PPoeUSbrh/Yp +3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 +nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNF +CSuGdXzfX2lXANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7na +xpeG0ILD5EJt/rDiZE4OJudANCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqX +KVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVvw9y4AyHqnxbxLFS1 +-----END CERTIFICATE-----`)) + + // D-Trust SBR Root CA 1 2022 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICXjCCAeOgAwIBAgIQUs/kjG2gSvc/gpcMgAmMlTAKBggqhkjOPQQDAzBJMQsw +CQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSMwIQYDVQQDExpELVRy +dXN0IFNCUiBSb290IENBIDEgMjAyMjAeFw0yMjA3MDYxMTMwMDBaFw0zNzA3MDYx +MTI5NTlaMEkxCzAJBgNVBAYTAkRFMRUwEwYDVQQKEwxELVRydXN0IEdtYkgxIzAh +BgNVBAMTGkQtVHJ1c3QgU0JSIFJvb3QgQ0EgMSAyMDIyMHYwEAYHKoZIzj0CAQYF +K4EEACIDYgAEWZM59oxJZijXYQzIq38Moy3foqR8kito1S5+HkDLtGhJfxKhq39X +nxkuYy5b/mZxDDMPud5rxIjDse/sOUDjlqvb5XuuH9z5r0aaakYGL8c3ZIsXYv6W +w6LuhOCwlzm8o4GPMIGMMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFPEpox4B +Eh09dVZNx1B8xRmqDxi3MA4GA1UdDwEB/wQEAwIBBjBKBgNVHR8EQzBBMD+gPaA7 +hjlodHRwOi8vY3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Nicl9yb290X2Nh +XzFfMjAyMi5jcmwwCgYIKoZIzj0EAwMDaQAwZgIxAJf53q5Lj5i1HkB/Mn1NVEPa +ic3CqpI80YIec8/6TJIg+2MnxfVzPQk996dhhozzagIxAOcvfLj1JYw7OR82q431 +hqIu4Xpk2mc5Av7+Mz/Zc7ZYWzr8sqTZYHh3zHmnpq5VvQ== +-----END CERTIFICATE-----`)) + + // D-Trust SBR Root CA 2 2022 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFrDCCA5SgAwIBAgIQVNWjlR49lbpyG5rQMSFKujANBgkqhkiG9w0BAQ0FADBJ +MQswCQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSMwIQYDVQQDExpE +LVRydXN0IFNCUiBSb290IENBIDIgMjAyMjAeFw0yMjA3MDcwNzMwMDBaFw0zNzA3 +MDcwNzI5NTlaMEkxCzAJBgNVBAYTAkRFMRUwEwYDVQQKEwxELVRydXN0IEdtYkgx +IzAhBgNVBAMTGkQtVHJ1c3QgU0JSIFJvb3QgQ0EgMiAyMDIyMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAryy8jjaM62SvUWrWbjxekTrqmsPKbPuqJ55k +IqlA37koRVrsU2EWKJjCiqR1eFCE3fogSJIHZUE1ZlESdGGdBwaFOTFXeyg/1Zyl +7FrpHEsnn84nBvM39VLYETMWQTof9WN4ZWOGyb/IAQQfbu7i7KwM7oKS4vYaDT85 ++Z1lk634uQXBPfg3gVbDoP4F7OCUFjojFgTapgqThXJtYTuhjUXW43++Fb02hAj2 +C4NrJqqiveCw56rgrmfE04KlDKmk8DN5DVA/8O+QPSS5f9IgbOqX87+c3EfeCWG9 +lHmVWgJ2NWDERyIN93ZjA9PG+4PGXaut7WklKwNbTSUAQeOMhxdSqOAFK0NNFBPK +5z9DIrw3pHXx9r867zIeru5YhpByugSsQEjvXMR4p6mPJ1rLeuxY8sIIWJBtTQOF +eXEVBQ5OPvnfDwX3XxRIViENM5KxrIzlGP6/D+7gBKq9IfJYtlyJCosYCSIaszXG +ZsL1MxWZgOAI+ZYvE4zu2reIxOk3tddq1zqETatwjNNOFFWgohD8ZNpn6PHLM93J +moqPli9Ygdn4mgBDzJD7VXb7huM3ASgMb/TpWU0Vd1FCSsw0uIBDUIHvV6UT26eU +eQ9Lyn4Xfa+jIWTocVVWjwawR+xZD11wWywWQvCGnnXea01ImITiVxi2nIKZZTqL +gHhXDEkCAwEAAaOBjzCBjDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRds4CU +G+WGv2i6FDSk9u5t8t3f5zAOBgNVHQ8BAf8EBAMCAQYwSgYDVR0fBEMwQTA/oD2g +O4Y5aHR0cDovL2NybC5kLXRydXN0Lm5ldC9jcmwvZC10cnVzdF9zYnJfcm9vdF9j +YV8yXzIwMjIuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQA0VC5YGFbNSr2X0/V9K9yv +D1HhTbwhS5P0AEQTBxALJRg+SFmW96Hhk5B4Zho9I+siqwGmjgxRM+ZtjDHurKQB +cDlI3sdmLGsNy3Ofh5LpPkcfuO8v7rdWjEiJ8DinFTmy7sA/F6RzAgicvAaKpMK3 +YWH5w9vE0Hp8Yd6xWJH13WVMLwv46z217Yq+dxy6WQISZnHlmCfODj2vUaJF+YL7 +WqWUcPeLhMNMZSWbe+IfMHCzQI467r3052jFnckpR3EOk8i1SE71ZrsHiHFpa3tI +jm/wEcS0yXAUmCC97afqAdpupZsS/j5EMLPw63VSwPTD+ncmpHeCLW/zKB5OlfAw +94n4LKJQW/K+Mn5sVNtyySpa4By2C9hSmlmh47ABJ8WgFlBm3OuubfSbWz2EbVuH +56mJu2644JtTicD/LkAaiUQuGENnOOR8cl/ZoyklQUE9HHcbZKjDVe5jcWZig/R/ +JpmgVDuhEm1wYs7T+bi9IvzUmtS74jgWL7d9OcKwqQPpnM9+GI123F8Ru+tC7FAJ +PlzskDHYGnK6P2kH7pg0wjSk1toT1qmE8gCGwFS6HhGw4rnEB7SR56rmMVZvsUTE +KmK8ybBlnDT8DBpT3yEXu8JtoQrm8bCqRAlQSTh6XXHiMS4ZsN+VQgR9hIjOCiNn +azidFt4G/ihwOKVarvyD7Q== +-----END CERTIFICATE-----`)) + + // T-TeleSec GlobalRoot Class 2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUx +KzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAd +BgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNl +YyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgxMDAxMTA0MDE0WhcNMzMxMDAxMjM1 +OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnBy +aXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50 +ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUd +AqSzm1nzHoqvNK38DcLZSBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiC +FoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/FvudocP05l03Sx5iRUKrERLMjfTlH6VJi +1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx9702cu+fjOlbpSD8DT6Iavq +jnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGVWOHAD3bZ +wI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/ +WSA2AHmgoCJrjNXyYdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhy +NsZt+U2e+iKo4YFWz827n+qrkRk4r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPAC +uvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNfvNoBYimipidx5joifsFvHZVw +IEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR3p1m0IvVVGb6 +g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN +9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlP +BSeOE6Fuwg== +-----END CERTIFICATE-----`)) + + // T-TeleSec GlobalRoot Class 3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUx +KzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAd +BgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNl +YyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgxMDAxMTAyOTU2WhcNMzMxMDAxMjM1 +OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnBy +aXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50 +ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN +8ELg63iIVl6bmlQdTQyK9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/ +RLyTPWGrTs0NvvAgJ1gORH8EGoel15YUNpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4 +hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZFiP0Zf3WHHx+xGwpzJFu5 +ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W0eDrXltM +EnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1 +A/d2O2GCahKqGFPrAyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOy +WL6ukK2YJ5f+AbGwUgC4TeQbIXQbfsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ +1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzTucpH9sry9uetuUg/vBa3wW30 +6gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7hP0HHRwA11fXT +91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml +e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4p +TpPDpFQUWw== +-----END CERTIFICATE-----`)) + + // Telekom Security SMIME ECC Root 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICRzCCAc2gAwIBAgIQFSrdFMkY0aRWQIamJa8HXzAKBggqhkjOPQQDAzBlMQsw +CQYDVQQGEwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBH +bWJIMS0wKwYDVQQDDCRUZWxla29tIFNlY3VyaXR5IFNNSU1FIEVDQyBSb290IDIw +MjEwHhcNMjEwMzE4MTEwODMwWhcNNDYwMzE3MjM1OTU5WjBlMQswCQYDVQQGEwJE +RTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBHbWJIMS0wKwYD +VQQDDCRUZWxla29tIFNlY3VyaXR5IFNNSU1FIEVDQyBSb290IDIwMjEwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAASwGY+ia7XHzQ8wmTcMw2Bb8fEnIFU9wJKLq1ehb3OD +IcJDEwxeiarHBTV5k2KQ1l0TH9F6oLyeEKdmfEYKsFdsv+ZUOTghbBJccczTWl9t +t6eG37Pf7sLniUGWNfYvSrWjQjBAMB0GA1UdDgQWBBQrywEMY8NTEqWoV6/QnIP7 +vZA6SzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQD +AwNoADBlAjEA1rxIkodHA8dwOyW2H65GZ3N0ACdL5KUEogPfXiitbl4DyN1onLa/ +lBBIlS8P/xiLAjABQDOel5dNBfJ0VAzNOf1qawnBJD9hjjiht+jXRBURYv8OYTdH +S0B/Sl+yZ1pzdcI= +-----END CERTIFICATE-----`)) + + // Telekom Security SMIME RSA Root 2023 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFtzCCA5+gAwIBAgIQDH5i9XlzO51Djotj7ZGVuDANBgkqhkiG9w0BAQwFADBl +MQswCQYDVQQGEwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0 +eSBHbWJIMS0wKwYDVQQDDCRUZWxla29tIFNlY3VyaXR5IFNNSU1FIFJTQSBSb290 +IDIwMjMwHhcNMjMwMzI4MTIwOTIyWhcNNDgwMzI3MjM1OTU5WjBlMQswCQYDVQQG +EwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBHbWJIMS0w +KwYDVQQDDCRUZWxla29tIFNlY3VyaXR5IFNNSU1FIFJTQSBSb290IDIwMjMwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDvxQ6LvjLSZ0f/Ckxnsyq/yMPF +keu1xx6R4WaoiItVIIAfUV53l54ZClzHazchfAM2AfSIJdmoLkGq/Ngm4JZAYnmu +V54DOBocsncUPumhctDk4DfRF0btUFx6WMX4K/d1L8+BnlostzqsoFmYBFEM/0nF +UP0e00eFSzNPoje1rwSaJzKdVtU/VWHji2+uUf6X/mkH+mJbJuYUeRWlEziuXze+ +lErWDYAWaaSRsjpJmHWdRhCKXHp/hKXorx7Hq7NaRrWjS/WmIzYARrHbBbYbzp56 +Mlya1XLDnYZNK4TTHrWI2hB4nCLDOyO16xMHvW9T7Jvsm9Nl9QcJ412nmbV+ho7V +Av+3hQnjRxTdlmYYNN4I1d/LGJliCyvsAF1SRNPGlvwyViWRz80ZO5U5PgKHmWO2 +1T40eg8RdYG8fQTKYLQoddcCUd1SAC7H/YnxXPPLpCcSOI+7+4nw5MQ4LL6CoHFh +YpGPSAwvK6mw8csQBOd0vzeQ708qQzWXEsYqcA3eLFVHeWMp9cofagZSHK4tJCKD +Iq/QqjC3Kh//ZSNYZZPIjn1AEDGGeNlVyzww8N5RKgA20idFX9jooSE9fkZWOylF +8R0FCc62QzDcRZAQMEyka4aLPz0vMZFx7ya59r6dsGzfEe5YP0N5hjmA8SYXB5jw +maowLENZFM7t4kAThQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FJrOrCrsAfplcN6XnfHSAIylo2S7MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgw +FoAUms6sKuwB+mVw3ped8dIAjKWjZLswDQYJKoZIhvcNAQEMBQADggIBAONQ/fVA +FiIJljoNqe+B5y4y8KHxSV57iA0Ecte+Z6i6He5Qu3JuetG7DHIwRsjV1wISFplO +Ht9alu6Pkb6uhvgQd6XEbkdhwPIm2U9haAVIdQgVpaF71biziXnm7fHzYQCGey4x +/qNc+Hk9tFuIe+Ajuw2hF/rLaA2Yd3EI4m1DdGvENsWUQaQA1lctmYqLIBIVAjIO +0knsgUjFaidS17JzVVOWPJ5PTLWg0E9X0GcoSGS+xri67GTPyHvFaucq5llXttbU +1sBnXNmeKAlAv/OpNTFlYAPLGWyClQMeXz/hvepJceVbtwtHFhsgiW2UmQx+iGwd +DfS3IRpZl6zL6L4XH5V8U5uvUFKqjQsur1rXYPIqaSq57lRwGKq99aE/0t2hYxkA ++KcM66N58nBZo/iiEgPsE//kAoY218HDpLXUpMI3RbaUcD3FveujFR3jNnoVaSpW +NDnPpZo2qsjtebzP9s4EUwvaslAjfLw+Jq3wDkO7JsuuwkDeNx8KoFHNY522T9jG +R3y82LTtnovzEeKotT7srnA+fiK7NUgXYGIUkTCjdj2mUTaLHw3dajEcpe3dlqNu +cg8TTaqnqVx4+QMSGJM3RRKJPfi+yr3ZvgzZGGSnyEE+dYIhOH1l9KDUE0sHeCn5 +nX7Mhz/E2i6I3eML3FpRWunZEk+eAtv3BSVR +-----END CERTIFICATE-----`)) + + // Telekom Security TLS ECC Root 2020 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICQjCCAcmgAwIBAgIQNjqWjMlcsljN0AFdxeVXADAKBggqhkjOPQQDAzBjMQsw +CQYDVQQGEwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBH +bWJIMSswKQYDVQQDDCJUZWxla29tIFNlY3VyaXR5IFRMUyBFQ0MgUm9vdCAyMDIw +MB4XDTIwMDgyNTA3NDgyMFoXDTQ1MDgyNTIzNTk1OVowYzELMAkGA1UEBhMCREUx +JzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJpdHkgR21iSDErMCkGA1UE +AwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgRUNDIFJvb3QgMjAyMDB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABM6//leov9Wq9xCazbzREaK9Z0LMkOsVGJDZos0MKiXrPk/O +tdKPD/M12kOLAoC+b1EkHQ9rK8qfwm9QMuU3ILYg/4gND21Ju9sGpIeQkpT0CdDP +f8iAC8GXs7s1J8nCG6NCMEAwHQYDVR0OBBYEFONyzG6VmUex5rNhTNHLq+O6zd6f +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA +MGQCMHVSi7ekEE+uShCLsoRbQuHmKjYC2qBuGT8lv9pZMo7k+5Dck2TOrbRBR2Di +z6fLHgIwN0GMZt9Ba9aDAEH9L1r3ULRn0SyocddDypwnJJGDSA3PzfdUga/sf+Rn +27iQ7t0l +-----END CERTIFICATE-----`)) + + // Telekom Security TLS RSA Root 2023 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIQIZxULej27HF3+k7ow3BXlzANBgkqhkiG9w0BAQwFADBj +MQswCQYDVQQGEwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0 +eSBHbWJIMSswKQYDVQQDDCJUZWxla29tIFNlY3VyaXR5IFRMUyBSU0EgUm9vdCAy +MDIzMB4XDTIzMDMyODEyMTY0NVoXDTQ4MDMyNzIzNTk1OVowYzELMAkGA1UEBhMC +REUxJzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJpdHkgR21iSDErMCkG +A1UEAwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgUlNBIFJvb3QgMjAyMzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAO01oYGA88tKaVvC+1GDrib94W7zgRJ9 +cUD/h3VCKSHtgVIs3xLBGYSJwb3FKNXVS2xE1kzbB5ZKVXrKNoIENqil/Cf2SfHV +cp6R+SPWcHu79ZvB7JPPGeplfohwoHP89v+1VmLhc2o0mD6CuKyVU/QBoCcHcqMA +U6DksquDOFczJZSfvkgdmOGjup5czQRxUX11eKvzWarE4GC+j4NSuHUaQTXtvPM6 +Y+mpFEXX5lLRbtLevOP1Czvm4MS9Q2QTps70mDdsipWol8hHD/BeEIvnHRz+sTug +BTNoBUGCwQMrAcjnj02r6LX2zWtEtefdi+zqJbQAIldNsLGyMcEWzv/9FIS3R/qy +8XDe24tsNlikfLMR0cN3f1+2JeANxdKz+bi4d9s3cXFH42AYTyS2dTd4uaNir73J +co4vzLuu2+QVUhkHM/tqty1LkCiCc/4YizWN26cEar7qwU02OxY2kTLvtkCJkUPg +8qKrBC7m8kwOFjQgrIfBLX7JZkcXFBGk8/ehJImr2BrIoVyxo/eMbcgByU/J7MT8 +rFEz0ciD0cmfHdRHNCk+y7AO+oMLKFjlKdw/fKifybYKu6boRhYPluV75Gp6SG12 +mAWl3G0eQh5C2hrgUve1g8Aae3g1LDj1H/1Joy7SWWO/gLCMk3PLNaaZlSJhZQNg ++y+TS/qanIA7AgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtqeX +gj10hZv3PJ+TmpV5dVKMbUcwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS2 +p5eCPXSFm/c8n5OalXl1UoxtRzANBgkqhkiG9w0BAQwFAAOCAgEAqMxhpr51nhVQ +pGv7qHBFfLp+sVr8WyP6Cnf4mHGCDG3gXkaqk/QeoMPhk9tLrbKmXauw1GLLXrtm +9S3ul0A8Yute1hTWjOKWi0FpkzXmuZlrYrShF2Y0pmtjxrlO8iLpWA1WQdH6DErw +M807u20hOq6OcrXDSvvpfeWxm4bu4uB9tPcy/SKE8YXJN3nptT+/XOR0so8RYgDd +GGah2XsjX/GO1WfoVNpbOms2b/mBsTNHM3dA+VKq3dSDz4V4mZqTuXNnQkYRIer+ +CqkbGmVps4+uFrb2S1ayLfmlyOw7YqPta9BO1UAJpB+Y1zqlklkg5LB9zVtzaL1t +xKITDmcZuI1CfmwMmm6gJC3VRRvcxAIU/oVbZZfKTpBQCHpCNfnqwmbU+AGuHrS+ +w6jv/naaoqYfRvaE7fzbzsQCzndILIyy7MMAo+wsVRjBfhnu4S/yrYObnqsZ38aK +L4x35bcF7DvB7L6Gs4a8wPfc5+pbrrLMtTWGS9DiP7bY+A4A7l3j941Y/8+LN+lj +X273CXE2whJdV/LItM3z7gLfEdxquVeEHVlNjM7IDiPCtyaaEBRx/pOyiriA8A4Q +ntOoUAw3gi/q4Iqd4Sw5/7W0cwDk90imc6y/st53BIe0o82bNSQ3+pCTE4FCxpgm +dTdmQRCsu/WU48IxK63nI1bMNSWSs1A= +-----END CERTIFICATE-----`)) + + // DigiCert Assured ID Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv +b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl +cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c +JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP +mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+ +wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4 +VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/ +AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB +AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun +pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC +dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf +fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm +NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx +H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe ++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== +-----END CERTIFICATE-----`)) + + // DigiCert Assured ID Root G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBl +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv +b3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl +cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSA +n61UQbVH35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4Htecc +biJVMWWXvdMX0h5i89vqbFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9Hp +EgjAALAcKxHad3A2m67OeYfcgnDmCXRwVWmvo2ifv922ebPynXApVfSr/5Vh88lA +bx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OPYLfykqGxvYmJHzDNw6Yu +YjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+RnlTGNAgMB +AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQW +BBTOw0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPI +QW5pJ6d1Ee88hjZv0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I +0jJmwYrA8y8678Dj1JGG0VDjA9tzd29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4Gni +lmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAWhsI6yLETcDbYz+70CjTVW0z9 +B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0MjomZmWzwPDCv +ON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo +IhNzbM8m9Yop5w== +-----END CERTIFICATE-----`)) + + // DigiCert Assured ID Root G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQsw +CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu +ZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3Qg +RzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBlMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJf +Zn4f5dwbRXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17Q +RSAPWXYQ1qAk8C3eNvJsKTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgFUaFNN6KDec6NHSrkhDAKBggqhkjOPQQD +AwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5FyYZ5eEJJZVrmDxxDnOOlY +JjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy1vUhZscv +6pZjamVFkpUBtA== +-----END CERTIFICATE-----`)) + + // DigiCert Global Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD +QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB +CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 +nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P +T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 +gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR +TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw +DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr +hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg +06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF +PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE-----`)) + + // DigiCert Global Root G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI +2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx +1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ +q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz +tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ +vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV +5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY +1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 +NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG +Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 +8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe +pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE-----`)) + + // DigiCert Global Root G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQsw +CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu +ZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAe +Fw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUw +EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x +IDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0CAQYF +K4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FG +fp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPO +Z9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd +BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIx +AK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/ +oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8 +sycX +-----END CERTIFICATE-----`)) + + // DigiCert High Assurance EV Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j +ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL +MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 +LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug +RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm ++9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW +PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM +xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB +Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 +hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg +EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA +FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec +nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z +eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF +hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 +Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe +vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep ++OkuE6N36B9K +-----END CERTIFICATE-----`)) + + // DigiCert SMIME ECC P384 Root G5 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICHDCCAaOgAwIBAgIQBT9uoAYBcn3tP8OjtqPW7zAKBggqhkjOPQQDAzBQMQsw +CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xKDAmBgNVBAMTH0Rp +Z2lDZXJ0IFNNSU1FIEVDQyBQMzg0IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN +NDYwMTE0MjM1OTU5WjBQMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs +IEluYy4xKDAmBgNVBAMTH0RpZ2lDZXJ0IFNNSU1FIEVDQyBQMzg0IFJvb3QgRzUw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQWnVXlttT7+2drGtShqtJ3lT6I5QeftnBm +ICikiOxwNa+zMv83E0qevAED3oTBuMbmZUeJ8hNVv82lHghgf61/6GGSKc8JR14L +HMAfpL/yW7yY75lMzHBrtrrQKB2/vgSjQjBAMB0GA1UdDgQWBBRzemuW20IHi1Jm +wmQyF/7gZ5AurTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAKBggq +hkjOPQQDAwNnADBkAjA3RPUygONx6/Rtz3zMkZrDbnHY0iNdkk2CQm1cYZX2kfWn +CPZql+mclC2YcP0ztgkCMAc8L7lYgl4Po2Kok2fwIMNpvwMsO1CnO69BOMlSSJHW +Dvu8YDB8ZD8SHkV/UT70pg== +-----END CERTIFICATE-----`)) + + // DigiCert SMIME RSA4096 Root G5 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFajCCA1KgAwIBAgIQBfa6BCODRst9XOa5W7ocVTANBgkqhkiG9w0BAQwFADBP +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJzAlBgNVBAMT +HkRpZ2lDZXJ0IFNNSU1FIFJTQTQwOTYgUm9vdCBHNTAeFw0yMTAxMTUwMDAwMDBa +Fw00NjAxMTQyMzU5NTlaME8xCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy +dCwgSW5jLjEnMCUGA1UEAxMeRGlnaUNlcnQgU01JTUUgUlNBNDA5NiBSb290IEc1 +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4Gpb2fj5fey1e+9f3Vw0 +2Npd0ctldashfFsA1IJvRYVBiqkSAnIy8BT1A3W7Y5dJD0CZCxoeVqfS0OGr3eUE +G+MfFBICiPWggAn2J5pQ8LrjouCsahSRtWs4EHqiMeGRG7e58CtbyHcJdrdRxDYK +mVNURCW3CTWGFwVWkz1BtwLXYh+KkhGH6hFt6ggR3LF4SEmS9rRRgHgj2P7hVho6 +kBNWNInV4pWLX96yzPs/OLeF9+qevy6hLi9NfWoRLjag/xEIBJVV4Bs7Z5OplFXq +Mu0GOn/Cf+OtEyfRNEGzMMO/tIj4A4Kk3z6reHegWZNx593rAAR7zEg5KOAeoxVp +yDayoQuX31XW75GcpPYW91EK7gMjkdwE/+DdOPYiAwDCB3EaEsnXRiqUG83Wuxvu +v75NUFiwC80wdin1z+W2ai92sLBpatBtZRg1fpO8chfBVULNL8Ilu/T9HaFkIlRd +4p5yQYRucZbqRQe2XnpKhp1zZHc4A9IPU6VVIMRN/2hvVanq3XHkT9mFo3xOKQKe +CwnyGlPMAKbd0TT2DcEwsZwCZKw17aWwKbHSlTMP0iAzvewjS/IZ+dqYZOQsMR8u +4Y0cBJUoTYxYzUvlc4KGjOyo1nlc+2S73AxMKPYXr+Jo1haGmNv8AdwxuvicDvko +Rkrh/ZYGRXkRaBdlXIsmh1sCAwEAAaNCMEAwHQYDVR0OBBYEFNGj1FcdT1XbdUxc +Qp5jFs60xjsfMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG +SIb3DQEBDAUAA4ICAQAHpwreU7ua63C/sjaQzeSnuPEM5F1aHXhl/Mm4HiMRV3xp +NW0B/1NQvwcOuscBP1gqlHUDqxwLI9wbih43PR1Yj3PZsypv3xCgWwynyrB/uSSi +ATUy5V5GQevYf3PnQumkUSZ3gQqo6w8KUJ1+iiBn/AuOOhHTxYxgGNlLsfzU8bRJ +Tq6H4dH7dqFf8wbPl5YM6Z51gVxTDSL8NuZJbnTbAIWNfCKgjvsQTNRiE1vvS3Im +i/xOio/+lxBTxXiLQmQbX+CJ/bsJf1DgVIUmEWodZflJKdx8Nt/7PffSrO4yjW6m +fTmcRcTKDfU7tHlTpS9Wx1HFikxkXZBDI45rTBd4zOi/9TvkqEjPrZsM3zJK09kS +jiN4DS2vn6+ePAnClwDtOmkccT8539OPxGb17zaUD/PdkraWX5Cm3XOqpiCUlCVq +CQxy5BMjYEyjyhcue2cA29DN6nofOSZXiTB3y07llUVPX/s2XD35ILU6ECVPkzJa +7sGW6OlWBLBJYU3seKidGMH/2OovVu+VK3sEXmfjVUDtOQT5C3n1aoxcD4makMfN +i97bJjWhbs2zQvKiDzsMjpP/FM/895P35EEIbhlSEQ9TGXN4DM/YhYH4rVXIsJ5G +Y6+cUu5cv/DAWzceCSDSPiPGoRVKDjZ+MMV5arwiiNkMUkAf3U4PZyYW0q0XHA== +-----END CERTIFICATE-----`)) + + // DigiCert TLS ECC P384 Root G5 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURp +Z2lDZXJ0IFRMUyBFQ0MgUDM4NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2 +MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ +bmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQgUm9vdCBHNTB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1TzvdlHJS +7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp +0zVozptjn4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICIS +B4CIfBFqMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 +BAMDA2gAMGUCMQCJao1H5+z8blUD2WdsJk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQ +LgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIxAJSdYsiJvRmEFOml+wG4 +DXZDjC5Ty3zfDBeWUA== +-----END CERTIFICATE-----`)) + + // DigiCert TLS RSA4096 Root G5 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBN +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMT +HERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN +NDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs +IEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS87IE+ +ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG0 +2C+JFvuUAT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgp +wgscONyfMXdcvyej/Cestyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZM +pG2T6T867jp8nVid9E6P/DsjyG244gXazOvswzH016cpVIDPRFtMbzCe88zdH5RD +nU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnVDdXifBBiqmvwPXbzP6Po +sMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9qTXeXAaDx +Zre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cd +Lvvyz6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvX +KyY//SovcfXWJL5/MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNe +XoVPzthwiHvOAbWWl9fNff2C+MIkwcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPL +tgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4EFgQUUTMc7TZArxfTJc1paPKv +TiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcN +AQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw +GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7H +PNtQOa27PShNlnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLF +O4uJ+DQtpBflF+aZfTCIITfNMBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQ +REtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/u4cnYiWB39yhL/btp/96j1EuMPik +AdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9GOUrYU9DzLjtxpdRv +/PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh47a+ +p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilw +MUc/dNAUFvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WF +qUITVuwhd4GTWgzqltlJyqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCK +ovfepEWFJqgejF0pW8hL2JpqA15w8oVPbEtoL8pU9ozaMv7Da4M/OMZ+ +-----END CERTIFICATE-----`)) + + // DigiCert Trusted Root G4 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y +ithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If +xp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV +ySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO +DCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ +jdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/ +CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi +EhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM +fRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY +uKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK +chYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t +9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +hjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2 +SV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd ++SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc +fFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa +sjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N +cCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N +0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie +4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI +r/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1 +/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm +gKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+ +-----END CERTIFICATE-----`)) + + // QuoVadis Root CA 1 G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQEL +BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc +BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00 +MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEgRzMwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakEPBtV +wedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWe +rNrwU8lmPNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF341 +68Xfuw6cwI2H44g4hWf6Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh +4Pw5qlPafX7PGglTvF0FBM+hSo+LdoINofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXp +UhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/lg6AnhF4EwfWQvTA9xO+o +abw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV7qJZjqlc +3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/G +KubX9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSt +hfbZxbGL0eUQMk1fiyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KO +Tk0k+17kBL5yG6YnLUlamXrXXAkgt3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOt +zCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZIhvcNAQELBQAD +ggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC +MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2 +cDMT/uFPpiN3GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUN +qXsCHKnQO18LwIE6PWThv6ctTr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5 +YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP+V04ikkwj+3x6xn0dxoxGE1nVGwv +b2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh3jRJjehZrJ3ydlo2 +8hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fawx/k +NSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNj +ZgKAvQU6O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhp +q1467HxpvMc7hU6eFbm0FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFt +nh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOVhMJKzRwuJIczYOXD +-----END CERTIFICATE-----`)) + + // QuoVadis Root CA 2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0x +GTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJv +b3QgQ0EgMjAeFw0wNjExMjQxODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNV +BAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMRswGQYDVQQDExJRdW9W +YWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCa +GMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6XJxg +Fyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55J +WpzmM+Yklvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bB +rrcCaoF6qUWD4gXmuVbBlDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp ++ARz8un+XJiM9XOva7R+zdRcAitMOeGylZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1 +ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt66/3FsvbzSUr5R/7mp/i +Ucw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1JdxnwQ5hYIiz +PtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og +/zOhD7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UH +oycR7hYQe7xFSkyyBNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuI +yV77zGHcizN300QyNQliBJIWENieJ0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1Ud +EwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBQahGK8SEwzJQTU7tD2 +A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGUa6FJpEcwRTEL +MAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT +ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2f +BluornFdLwUvZ+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzn +g/iN/Ae42l9NLmeyhP3ZRPx3UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2Bl +fF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodmVjB3pjd4M1IQWK4/YY7yarHvGH5K +WWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK+JDSV6IZUaUtl0Ha +B0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrWIozc +hLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPR +TUIZ3Ph1WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWD +mbA4CD/pXvk1B+TJYm5Xf6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0Z +ohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y +4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8VCLAAVBpQ570su9t+Oza +8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u +-----END CERTIFICATE-----`)) + + // QuoVadis Root CA 2 G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL +BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc +BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00 +MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf +qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW +n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym +c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+ +O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1 +o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j +IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq +IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz +8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh +vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l +7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG +cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD +ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 +AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC +roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga +W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n +lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE ++V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV +csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd +dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg +KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM +HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4 +WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M +-----END CERTIFICATE-----`)) + + // QuoVadis Root CA 3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0x +GTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJv +b3QgQ0EgMzAeFw0wNjExMjQxOTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNV +BAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMRswGQYDVQQDExJRdW9W +YWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDM +V0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNggDhoB +4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUr +H556VOijKTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd +8lyyBTNvijbO0BNO/79KDDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9Cabwv +vWhDFlaJKjdhkf2mrk7AyxRllDdLkgbvBNDInIjbC3uBr7E9KsRlOni27tyAsdLT +mZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwpp5ijJUMv7/FfJuGITfhe +btfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8nT8KKdjc +T5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDt +WAEXMJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZ +c6tsgLjoC2SToJyMGf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A +4iLItLRkT9a6fUg+qGkM17uGcclzuD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYD +VR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHTBgkrBgEEAb5YAAMwgcUwgZMG +CCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmljYXRlIGNvbnN0 +aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0 +aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVu +dC4wLQYIKwYBBQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2Nw +czALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4G +A1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4ywLQoUmkRzBFMQswCQYDVQQGEwJC +TTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UEAxMSUXVvVmFkaXMg +Um9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZVqyM0 +7ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSem +d1o417+shvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd ++LJ2w/w4E6oM3kJpK27zPOuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B +4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadN +t54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp8kokUvd0/bpO5qgdAm6x +DYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBCbjPsMZ57 +k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6s +zHXug/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0j +Wy10QJLZYxkNc91pvGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeT +mJlglFwjz1onl14LBQaTNx47aTbrqZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK +4SVhM7JZG+Ju1zdXtg2pEto= +-----END CERTIFICATE-----`)) + + // QuoVadis Root CA 3 G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQEL +BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc +BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00 +MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMgRzMwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286IxSR +/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNu +FoM7pmRLMon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXR +U7Ox7sWTaYI+FrUoRqHe6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+c +ra1AdHkrAj80//ogaX3T7mH1urPnMNA3I4ZyYUUpSFlob3emLoG+B01vr87ERROR +FHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3UVDmrJqMz6nWB2i3ND0/k +A9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f75li59wzw +eyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634Ryl +sSqiMd5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBp +VzgeAVuNVejH38DMdyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0Q +A4XN8f+MFrXBsj6IbGB/kE+V9/YtrQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ +ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZIhvcNAQELBQAD +ggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px +KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnI +FUBhynLWcKzSt/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5Wvv +oxXqA/4Ti2Tk08HS6IT7SdEQTXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFg +u/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9DuDcpmvJRPpq3t/O5jrFc/ZSXPsoaP +0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGibIh6BJpsQBJFxwAYf +3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmDhPbl +8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+ +DhcI00iX0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HN +PlopNLk9hM6xZdRZkZFWdSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ +ywaZWWDYWGWVjUTR939+J399roD1B0y2PpxxVJkES/1Y+Zj0 +-----END CERTIFICATE-----`)) + + // DIGITALSIGN GLOBAL ROOT ECDSA CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICajCCAfCgAwIBAgIUNi2PcoiiKCfkAP8kxi3k6/qdtuEwCgYIKoZIzj0EAwMw +ZDELMAkGA1UEBhMCUFQxKjAoBgNVBAoMIURpZ2l0YWxTaWduIENlcnRpZmljYWRv +cmEgRGlnaXRhbDEpMCcGA1UEAwwgRElHSVRBTFNJR04gR0xPQkFMIFJPT1QgRUNE +U0EgQ0EwHhcNMjEwMTIxMTEwNzUwWhcNNDYwMTE1MTEwNzUwWjBkMQswCQYDVQQG +EwJQVDEqMCgGA1UECgwhRGlnaXRhbFNpZ24gQ2VydGlmaWNhZG9yYSBEaWdpdGFs +MSkwJwYDVQQDDCBESUdJVEFMU0lHTiBHTE9CQUwgUk9PVCBFQ0RTQSBDQTB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABG4Lo6szTRzqSuj8BI0UoH3wCCxfg6uT0dJ7utdJ +fY/sElBf1LnL5fD5M2MfyVfsQNgRC5foUhbMKY70BoYeONw9V8Tuqr3IVAQmWicT +UUc9Hx8ajqiVpDPQzEfMbbj8SKNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME +GDAWgBTOr0qLGnXi8TjnAvAWrV7qZNV7tDAdBgNVHQ4EFgQUzq9Kixp14vE45wLw +Fq1e6mTVe7QwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMAqIxHGc +RANNjbTHvKiu2TAnNWprFmPX/OdZ4aeJG0wxmiNVRObzQyHVRydvbVcBqgIxAPuy +6uKXf1G1n0jrvG81iahkcKtXds3AxhRgyn/iggBz98w16o4km+UIWccEjHN4/g== +-----END CERTIFICATE-----`)) + + // DIGITALSIGN GLOBAL ROOT RSA CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIUXVnIyqsJV/XmtdoplARq/8XUlYcwDQYJKoZIhvcNAQEN +BQAwYjELMAkGA1UEBhMCUFQxKjAoBgNVBAoMIURpZ2l0YWxTaWduIENlcnRpZmlj +YWRvcmEgRGlnaXRhbDEnMCUGA1UEAwweRElHSVRBTFNJR04gR0xPQkFMIFJPT1Qg +UlNBIENBMB4XDTIxMDEyMTEwNTAzNFoXDTQ2MDExNTEwNTAzNFowYjELMAkGA1UE +BhMCUFQxKjAoBgNVBAoMIURpZ2l0YWxTaWduIENlcnRpZmljYWRvcmEgRGlnaXRh +bDEnMCUGA1UEAwweRElHSVRBTFNJR04gR0xPQkFMIFJPT1QgUlNBIENBMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyIe2ONMc8N4S+IPHxIriibi0Inp4 ++AxmUWh2NwrVT8JaCLgWXPdyAQk3hIEqVGvXktBs+qinQxI06w7bNw8p/ooxUULo +S5yQqMgsEdP9oCl+zt6U9oLgWLRORSXxIvI90w97VBrcMrbWUU5+QbRXuCzGuQ4u +ylfx1cjTWOel6UIRrtMgJZRp14/Kog3D058HaD8V0mcuU/12gpsLc6kpDZ4RkxQI +mOyeVBJKVqIGFexrbC6SYC6GDa6CH1FN47IH1xAZVyL2qWlEhPPZPaAGv8yIfn/1 +zlulwipqdELqb6b/+Wix0F+9kdJVbzNXTB6d5OKLwYVloOBqnAAAiJLdWAgW8nAx +qBzh3r1OcenWvn61oVrDTfe/m72UpP31qlOTRskmAQRwxKBxus4lZvuRflVw7kkK +TWJ/wlCacvIYZ53pRag0hOj4gfbRWiIeB087s3/dEaVz3L6pGTppqW0bMuKJqqUn +C1p+dOIPZDldfly5wRf8x41eyewk7dLyP3qERTcCvj5rWcTmWxZtwKqeqrVZLixw +VZzMmZaYJFTRjtrKtBG0t3BDH2+QCyCgqHYTZdvbI1p1S6ELMXcK7n1oYRoTjOpR +flxWo1dMXaHrE2W/VBTM8+7c1+w8l/J4Vrjfclxw/M4G3Z/SBzHv51KRns2618AY +RAcxZUkyaRNK648CAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAW +gBS1Nrw8jBqrLPZZGS2DFNqTJRXWhjAdBgNVHQ4EFgQUtTa8PIwaqyz2WRktgxTa +kyUV1oYwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDQUAA4ICAQAU+zElODH4 +ygiyI3Y4rfjTWfXMtFcl4US+fvwW7K76Jp9PZxZKVvD97ccZATSOkFot1oBc7HHS +gSWCHgBx35rR1R0iu9Gl82IPtOvcJHP+plbNmhTFBDUWMaIH66UA4rb4X3L9P2FJ +jt5+TTjXeh50N2xR3L4ABLg4FPMgwe2bpyP9DUKEHX/yc8PQeGPxn+zXW+nxvmyg +SwOejWnhFNqIEIEjU//aVCsLxrmWlQQYRvN7qJfYW2ik5DgcDkXlmNMJrppe7LN5 +DTly8vSUnQ6eYCLmqPZMhc0HgjpoOc09X+M49LavO2tKn2BRRaJAAuWqDOM+0XjU +onScJroFmihwSj6mC9AdSfC6+K5BEH6kBxK9qM8pPVe7x/FDRwA+rnAYWiB7Ccs6 +OnCA5UxgmMEVwR1K98jwm+FyreddaFgLBLGMvJ+3+26LWwRV++sjVdd4UNoly74n +NrskGnkcUdH+E7v/eCzcpL4v9sVLU8+nTJlecKxZiASuZAS/e6Z6TdPod72hflAV +8+9JMIVNIVeq2yx1l62BAYeisXCdHgZaA2CxP6ZtgizUFLGBpeg9iB20cixYN4qO +OJS4c92p4Lj2d6KzfFjermk6tYulGrvy2HQGnP1icyAhdrF+cJ4Z1OsXYhk4mc02 +K0f+McvfueSsCNPYpuvUnn5LZKRVXSsXyQ== +-----END CERTIFICATE-----`)) + + // CA Disig Root R2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNV +BAYTAlNLMRMwEQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMu +MRkwFwYDVQQDExBDQSBEaXNpZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQy +MDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sxEzARBgNVBAcTCkJyYXRpc2xhdmEx +EzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERpc2lnIFJvb3QgUjIw +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbCw3Oe +NcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNH +PWSb6WiaxswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3I +x2ymrdMxp7zo5eFm1tL7A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbe +QTg06ov80egEFGEtQX6sx3dOy1FU+16SGBsEWmjGycT6txOgmLcRK7fWV8x8nhfR +yyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqVg8NTEQxzHQuyRpDRQjrO +QG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa5Beny912 +H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJ +QfYEkoopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUD +i/ZnWejBBhG93c+AAk9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORs +nLMOPReisjQS1n6yqEm70XooQL6iFh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1 +rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5uQu0wDQYJKoZI +hvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM +tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqf +GopTpti72TVVsRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkb +lvdhuDvEK7Z4bLQjb/D907JedR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka ++elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W81k/BfDxujRNt+3vrMNDcTa/F1bal +TFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjxmHHEt38OFdAlab0i +nSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01utI3 +gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18Dr +G5gPcFw0sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3Os +zMOl6W8KjptlwlCFtaOgUxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8x +L4ysEr3vQCj8KWefshNPZiTEUxnpHikV7+ZtsH8tZ/3zbBt1RqPlShfppNcL +-----END CERTIFICATE-----`)) + + // GLOBALTRUST 2020 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgjCCA2qgAwIBAgILWku9WvtPilv6ZeUwDQYJKoZIhvcNAQELBQAwTTELMAkG +A1UEBhMCQVQxIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkw +FwYDVQQDExBHTE9CQUxUUlVTVCAyMDIwMB4XDTIwMDIxMDAwMDAwMFoXDTQwMDYx +MDAwMDAwMFowTTELMAkGA1UEBhMCQVQxIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9u +aXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVTVCAyMDIwMIICIjANBgkq +hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri5WrRsc7/aVj6B3GyvTY4+ETUWiD59b +RatZe1E0+eyLinjF3WuvvcTfk0Uev5E4C64OFudBc/jbu9G4UeDLgztzOG53ig9Z +YybNpyrOVPu44sB8R85gfD+yc/LAGbaKkoc1DZAoouQVBGM+uq/ufF7MpotQsjj3 +QWPKzv9pj2gOlTblzLmMCcpL3TGQlsjMH/1WljTbjhzqLL6FLmPdqqmV0/0plRPw +yJiT2S0WR5ARg6I6IqIoV6Lr/sCMKKCmfecqQjuCgGOlYx8ZzHyyZqjC0203b+J+ +BlHZRYQfEs4kUmSFC0iAToexIiIwquuuvuAC4EDosEKAA1GqtH6qRNdDYfOiaxaJ +SaSjpCuKAsR49GiKweR6NrFvG5Ybd0mN1MkGco/PU+PcF4UgStyYJ9ORJitHHmkH +r96i5OTUawuzXnzUJIBHKWk7buis/UDr2O1xcSvy6Fgd60GXIsUf1DnQJ4+H4xj0 +4KlGDfV0OoIu0G4skaMxXDtG6nsEEFZegB31pWXogvziB4xiRfUg3kZwhqG8k9Me +dKZssCz3AwyIDMvUclOGvGBG85hqwvG/Q/lwIHfKN0F5VVJjjVsSn8VoxIidrPIw +q7ejMZdnrY8XD2zHc+0klGvIg5rQmjdJBKuxFshsSUktq6HQjJLyQUp5ISXbY9e2 +nKd+Qmn7OmMCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFNwuH9FhN3nkq9XVsxJxaD1qaJwiMB8GA1UdIwQYMBaAFNwu +H9FhN3nkq9XVsxJxaD1qaJwiMA0GCSqGSIb3DQEBCwUAA4ICAQCR8EICaEDuw2jA +VC/f7GLDw56KoDEoqoOOpFaWEhCGVrqXctJUMHytGdUdaG/7FELYjQ7ztdGl4wJC +XtzoRlgHNQIw4Lx0SsFDKv/bGtCwr2zD/cuz9X9tAy5ZVp0tLTWMstZDFyySCstd +6IwPS3BD0IL/qMy/pJTAvoe9iuOTe8aPmxadJ2W8esVCgmxcB9CpwYhgROmYhRZf ++I/KARDOJcP5YBugxZfD0yyIMaK9MOzQ0MAS8cE54+X1+NZK3TTN+2/BT+MAi1bi +kvcoskJ3ciNnxz8RFbLEAwW+uxF7Cr+obuf/WEPPm2eggAe2HcqtbepBEX4tdJP7 +wry+UUTF72glJ4DjyKDUEuzZpTcdN3y0kcra1LGWge9oXHYQSa9+pTeAsRxSvTOB +TI/53WXZFM2KJVj04sWDpQmQ1GwUY7VA3+vA/MRYfg0UFodUJ25W5HCEuGwyEn6C +MUO+1918oa2u1qsgEu8KwxCMSZY13At1XrFP1U80DhEgB3VDRemjEdqso5nCtnkn +4rnvyOL2NSl6dPrFf4IFYqYK6miyeUcGbvJXqBUzxvd4Sj1Ce2t+/vdG6tHrju+I +aFvowdlxfv1k7/9nR4hYJS8+hge9+6jlgqispdNpQ80xiEmEU5LAsTkbOYMBMMTy +qfrQA71yN2BWHzZ8vTmR9W0Nv3vXkg== +-----END CERTIFICATE-----`)) + + // emSign ECC Root CA - C3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQG +EwJVUzETMBEGA1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMx +IDAeBgNVBAMTF2VtU2lnbiBFQ0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAw +MFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2ln +biBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQDExdlbVNpZ24gRUND +IFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd6bci +MK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4Ojavti +sIGJAnB9SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0O +BBYEFPtaSNCAIEDyqOkAB2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB +Af8EBTADAQH/MAoGCCqGSM49BAMDA2gAMGUCMQC02C8Cif22TGK6Q04ThHK1rt0c +3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwUZOR8loMRnLDRWmFLpg9J +0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ== +-----END CERTIFICATE-----`)) + + // emSign ECC Root CA - G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG +EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo +bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ +TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s +b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0 +WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS +fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB +zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq +hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB +CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD ++JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE-----`)) + + // emSign Root CA - C1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkG +A1UEBhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEg +SW5jMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAw +MFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2ln +biBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNpZ24gUm9v +dCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+upufGZ +BczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZ +HdPIWoU/Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH +3DspVpNqs8FqOp099cGXOFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvH +GPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4VI5b2P/AgNBbeCsbEBEV5f6f9vtKppa+c +xSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleoomslMuoaJuvimUnzYnu3Yy1 +aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+XJGFehiq +TbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87 +/kOXSTKZEhVb3xEp/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4 +kqNPEjE2NuLe/gDEo2APJ62gsIq1NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrG +YQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9wC68AivTxEDkigcxHpvOJpkT ++xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQBmIMMMAVSKeo +WXzhriKi4gp6D/piq1JM4fHfyr6DDUI= +-----END CERTIFICATE-----`)) + + // emSign Root CA - G1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD +VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU +ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH +MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO +MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv +Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz +f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO +8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq +d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM +tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt +Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB +o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD +AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x +PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM +wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d +GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH +6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby +RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx +iN66zB+Afko= +-----END CERTIFICATE-----`)) + + // AffirmTrust Commercial + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UE +BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz +dCBDb21tZXJjaWFsMB4XDTEwMDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDEL +MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp +cm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6EqdbDuKP +Hx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yr +ba0F8PrVC8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPAL +MeIrJmqbTFeurCA+ukV6BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1 +yHp52UKqK39c/s4mT6NmgTWvRLpUHhwwMmWd5jyTXlBOeuM61G7MGvv50jeuJCqr +VwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNVHQ4EFgQUnZPGU4teyq8/ +nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ +KoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYG +XUPGhi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNj +vbz4YYCanrHOQnDiqX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivt +Z8SOyUOyXGsViQK8YvxO8rUzqrJv0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9g +N53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0khsUlHRUe072o0EclNmsxZt9YC +nlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= +-----END CERTIFICATE-----`)) + + // AffirmTrust Networking + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UE +BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz +dCBOZXR3b3JraW5nMB4XDTEwMDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDEL +MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp +cm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SEHi3y +YJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbua +kCNrmreIdIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRL +QESxG9fhwoXA3hA/Pe24/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp +6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gbh+0t+nvujArjqWaJGctB+d1ENmHP4ndG +yH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNVHQ4EFgQUBx/S55zawm6i +QLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ +KoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfO +tDIuUFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzu +QY0x2+c06lkh1QF612S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZ +Lgo/bNjR9eUJtGxUAArgFU2HdW23WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4u +olu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9/ZFvgrG+CJPbFEfxojfHRZ48 +x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s= +-----END CERTIFICATE-----`)) + + // AffirmTrust Premium + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UE +BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVz +dCBQcmVtaXVtMB4XDTEwMDEyOTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkG +A1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1U +cnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxBLf +qV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtnBKAQ +JG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ ++jjeRFcV5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrS +s8PhaJyJ+HoAVt70VZVs+7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5 +HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmdGPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d7 +70O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5Rp9EixAqnOEhss/n/fauG +V+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NIS+LI+H+S +qHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S +5u046uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4Ia +C1nEWTJ3s7xgaVY5/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TX +OwF0lkLgAOIua+rF7nKsu7/+6qqo+Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYE +FJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByvMiPIs0laUZx2 +KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg +Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B +8OWycvpEgjNC6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQ +MKSOyARiqcTtNd56l+0OOF6SL5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc +0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK+4w1IX2COPKpVJEZNZOUbWo6xbLQ +u4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmVBtWVyuEklut89pMF +u+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFgIxpH +YoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8 +GKa1qF60g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaO +RtGdFNrHF+QFlozEJLUbzxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6e +KeC2uAloGRwYQw== +-----END CERTIFICATE-----`)) + + // AffirmTrust Premium ECC + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMC +VVMxFDASBgNVBAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQ +cmVtaXVtIEVDQzAeFw0xMDAxMjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJ +BgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1UcnVzdDEgMB4GA1UEAwwXQWZmaXJt +VHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNMF4bFZ0D +0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQN8O9 +ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0G +A1UdDgQWBBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/Vs +aobgxCd05DhT1wV/GzTjxi+zygk8N53X57hG8f2h4nECMEJZh0PUUd+60wkyWs6I +flc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKMeQ== +-----END CERTIFICATE-----`)) + + // Atos TrustedRoot 2011 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UE +AwwVQXRvcyBUcnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQG +EwJERTAeFw0xMTA3MDcxNDU4MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMM +FUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsGA1UECgwEQXRvczELMAkGA1UEBhMC +REUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVhTuXbyo7LjvPpvMp +Nb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr54rM +VD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+ +SZFhyBH+DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ +4J7sVaE3IqKHBAUsR320HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0L +cp2AMBYHlT8oDv3FdU9T1nSatCQujgKRz3bFmx5VdJx4IbHwLfELn8LVlhgf8FQi +eowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7Rl+lwrrw7GWzbITAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZbNshMBgG +A1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3 +DQEBCwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8j +vZfza1zv7v1Apt+hk6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kP +DpFrdRbhIfzYJsdHt6bPWHJxfrrhTZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pc +maHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a961qn8FYiqTxlVMYVqL2Gns2D +lmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G3mB/ufNPRJLv +KrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed +-----END CERTIFICATE-----`)) + + // Atos TrustedRoot Root CA ECC G2 2020 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICMTCCAbagAwIBAgIMC3MoERh0MBzvbwiEMAoGCCqGSM49BAMDMEsxCzAJBgNV +BAYTAkRFMQ0wCwYDVQQKDARBdG9zMS0wKwYDVQQDDCRBdG9zIFRydXN0ZWRSb290 +IFJvb3QgQ0EgRUNDIEcyIDIwMjAwHhcNMjAxMjE1MDgzOTEwWhcNNDAxMjEwMDgz +OTA5WjBLMQswCQYDVQQGEwJERTENMAsGA1UECgwEQXRvczEtMCsGA1UEAwwkQXRv +cyBUcnVzdGVkUm9vdCBSb290IENBIEVDQyBHMiAyMDIwMHYwEAYHKoZIzj0CAQYF +K4EEACIDYgAEyFyAyk7CKB9XvzjmYSP80KlblhYWwwxeFaWQCf84KLR6HgrWUyrB +u5BAdDfpgeiNL2gBNXxSLtj0WLMRHFvZhxiTkS3sndpsnm2ESPzCiQXrmBMCAWxT +Hg5JY1hHsa/Co2MwYTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFFsfxHFs +shufvlwfjP2ztvuzDgmHMB0GA1UdDgQWBBRbH8RxbLIbn75cH4z9s7b7sw4JhzAO +BgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAOzgmf3d5FTByx/oPijX +FVlKgspTMOzrNqW5yM6TR1bIYabhbZJTlY/241VT8N165wIxALCH1RuzYPyRjYDK +ohtRSzhUy6oee9flRJUWLzxEeC4luuqQ5OxS7lfsA4TzXtsWDQ== +-----END CERTIFICATE-----`)) + + // Atos TrustedRoot Root CA ECC TLS 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICFTCCAZugAwIBAgIQPZg7pmY9kGP3fiZXOATvADAKBggqhkjOPQQDAzBMMS4w +LAYDVQQDDCVBdG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgRUNDIFRMUyAyMDIxMQ0w +CwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0yMTA0MjIwOTI2MjNaFw00MTA0 +MTcwOTI2MjJaMEwxLjAsBgNVBAMMJUF0b3MgVHJ1c3RlZFJvb3QgUm9vdCBDQSBF +Q0MgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNVBAYTAkRFMHYwEAYHKoZI +zj0CAQYFK4EEACIDYgAEloZYKDcKZ9Cg3iQZGeHkBQcfl+3oZIK59sRxUM6KDP/X +tXa7oWyTbIOiaG6l2b4siJVBzV3dscqDY4PMwL502eCdpO5KTlbgmClBk1IQ1SQ4 +AjJn8ZQSb+/Xxd4u/RmAo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR2 +KCXWfeBmmnoJsmo7jjPXNtNPojAOBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMD +aAAwZQIwW5kp85wxtolrbNa9d+F851F+uDrNozZffPc8dz7kUK2o59JZDCaOMDtu +CCrCp1rIAjEAmeMM56PDr9NJLkaCI2ZdyQAUEv049OGYa3cpetskz2VAv9LcjBHo +9H1/IISpQuQo +-----END CERTIFICATE-----`)) + + // Atos TrustedRoot Root CA RSA G2 2020 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFfzCCA2egAwIBAgIMR7opRlU+FpKXsKtAMA0GCSqGSIb3DQEBDAUAMEsxCzAJ +BgNVBAYTAkRFMQ0wCwYDVQQKDARBdG9zMS0wKwYDVQQDDCRBdG9zIFRydXN0ZWRS +b290IFJvb3QgQ0EgUlNBIEcyIDIwMjAwHhcNMjAxMjE1MDg0MTIzWhcNNDAxMjEw +MDg0MTIyWjBLMQswCQYDVQQGEwJERTENMAsGA1UECgwEQXRvczEtMCsGA1UEAwwk +QXRvcyBUcnVzdGVkUm9vdCBSb290IENBIFJTQSBHMiAyMDIwMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAljGFSqoPMv554UOHnPsjt45/DVS9x2KTd+Qc +NQR2owOLIu7EhN2lk25uso4JA+tRFjEXqmkVGA5ndCNe6pp9tTk+PYKpa+H+qRyw +rVpNTHiDQYvP8h1impgEnGPpq2X+SB0kZQdHPrmRLumdm38aNak0sLflcDPvSnJR +tge/YD8qn51U3/PXlElRA1pAqWjdEVlc+HamvFBSEO2s7JXg1INrSdoKT5mD3jKD +SINnlbJ+54GFPc2C98oC7W2IXQiNuDW/KmkwmbtL0UHbRaCTmVGBkDYIqoq26I+z +y+7lRg1ydfVJbOGify+87YSmN+7ewk85Tvae8MnRmzCdSW3h2v8SEIzW5Zl7BbZ9 +sAnHpPiyHDmVOTP0Nc4lYnuwXyDzy234bFIUZESP08ipdgflr3GZLS0EJUh2r8Pn +zEPyB7xKJCQ33fpulAlvTF4BtP5U7COWpV7dhv/pRirx6NzspT2vb6oOD7R1+j4I +uSZFT2aGTLwZuOHVNe6ChMjTqxLnzXMzYnf0F8u9NHYqBc6V5Xh5S56wjfk8WDiR +6l6HOMC3Qv2qTIcjrQQgsX52Qtq7tha6V8iOE/p11QhMrziRqu+P+p9JLlR8Clax +evrETi/Uo/oWitCV5Zem/8P8fA5HWPN/B3sS3Fc/LeOhTVtSTDOHmagJe2x+DvLP +VkKe6wUCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQgJfMH +/adv8ZbukRBpzJrvfchoeDAdBgNVHQ4EFgQUICXzB/2nb/GW7pEQacya733IaHgw +DgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQAkK06Y8h0X7dl2JrYw +M+hpRaFRS1LYejowtuQS6r+fTOAEpPY1xv6hMPdThZKtVAVXX5LlKt42J557E0fJ +anWv/PM35wz1PQFztWlR+L1Z0boL+Lq6ZCdDs3yDlYrnnhOW129KlkFJiw4grRbG +96aHW4gSiYuJyhLSVq8iASFG6auYP6eI3uTLKpp1Gfo5XgkF1wMyGrgXUQjHAEB9 +9L74DFn0aXZu06RYW14mc+RCVQZeeEAP0zif7yZRcHSR8XdiAejZy+uh3zkyHbtr +/XH+68+l5hT9AIATxpoASLCZBemugEj7CT9RFLW552BNTcovgSHuUgxletz1iUlM +MJI0WIAyWbEN/yRhD+cKQtB7vPiOJ0c/cJ0n2bYGPaW7y16Prg5Tx5xqbztMD6NA +cKiaB87UblsHotLiVLa9bzNyY61RmOGPdvFqBzgl/vZizl/bY8Jume8G3LneGRro +VD190nZ12V4+MkinjPKecgz4uFi4FyOlFId1WHoAgQciOWpMlKC1otunLMGw8aOb +wEz3bXDqMZ/xrn0+cyjZod/6k/CbsPDizSUgde/ifTIFyZt27su9MR75lJhLJFhW +SMDeBky9pjRd7RZhY3P7GeL6W9iXddRtnmA5XpSLAizrmc5gKm4bjKdLvP025pgf +ZfJ/8eOPTIBGNli2oWXLzhxEdQ== +-----END CERTIFICATE-----`)) + + // Atos TrustedRoot Root CA RSA TLS 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFZDCCA0ygAwIBAgIQU9XP5hmTC/srBRLYwiqipDANBgkqhkiG9w0BAQwFADBM +MS4wLAYDVQQDDCVBdG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgUlNBIFRMUyAyMDIx +MQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0yMTA0MjIwOTIxMTBaFw00 +MTA0MTcwOTIxMDlaMEwxLjAsBgNVBAMMJUF0b3MgVHJ1c3RlZFJvb3QgUm9vdCBD +QSBSU0EgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNVBAYTAkRFMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtoAOxHm9BYx9sKOdTSJNy/BBl01Z +4NH+VoyX8te9j2y3I49f1cTYQcvyAh5x5en2XssIKl4w8i1mx4QbZFc4nXUtVsYv +Ye+W/CBGvevUez8/fEc4BKkbqlLfEzfTFRVOvV98r61jx3ncCHvVoOX3W3WsgFWZ +kmGbzSoXfduP9LVq6hdKZChmFSlsAvFr1bqjM9xaZ6cF4r9lthawEO3NUDPJcFDs +GY6wx/J0W2tExn2WuZgIWWbeKQGb9Cpt0xU6kGpn8bRrZtkh68rZYnxGEFzedUln +nkL5/nWpo63/dgpnQOPF943HhZpZnmKaau1Fh5hnstVKPNe0OwANwI8f4UDErmwh +3El+fsqyjW22v5MvoVw+j8rtgI5Y4dtXz4U2OLJxpAmMkokIiEjxQGMYsluMWuPD +0xeqqxmjLBvk1cbiZnrXghmmOxYsL3GHX0WelXOTwkKBIROW1527k2gV+p2kHYzy +geBYBr3JtuP2iV2J+axEoctr+hbxx1A9JNr3w+SH1VbxT5Aw+kUJWdo0zuATHAR8 +ANSbhqRAvNncTFd+rrcztl524WWLZt+NyteYr842mIycg5kDcPOvdO3GDjbnvezB +c6eUWsuSZIKmAMFwoW4sKeFYV+xafJlrJaSQOoD0IJ2azsct+bJLKZWD6TWNp0lI +pw9MGZHQ9b8Q4HECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +dEmZ0f+0emhFdcN+tNzMzjkz2ggwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +DAUAA4ICAQAjQ1MkYlxt/T7Cz1UAbMVWiLkO3TriJQ2VSpfKgInuKs1l+NsW4AmS +4BjHeJi78+xCUvuppILXTdiK/ORO/auQxDh1MoSf/7OwKwIzNsAQkG8dnK/haZPs +o0UvFJ/1TCplQ3IM98P4lYsU84UgYt1UU90s3BiVaU+DR3BAM1h3Egyi61IxHkzJ +qM7F78PRreBrAwA0JrRUITWXAdxfG/F851X6LWh3e9NpzNMOa7pNdkTWwhWaJuyw +xfW70Xp0wmzNxbVe9kzmWy2B27O3Opee7c9GslA9hGCZcbUztVdF5kJHdWoOsAgM +rr3e97sPWD2PAzHoPYJQyi9eDF20l74gNAf0xBLh7tew2VktafcxBPTy+av5EzH4 +AXcOPUIjJsyacmdRIXrMPIWo6iFqO9taPKU0nprALN+AnCng33eU0aKAQv9qTFsR +0PXNor6uzFFcw9VUewyu1rkGd4Di7wcaaMxZUa1+XGdrudviB0JbuAEFWDlN5LuY +o7Ey7Nmj1m+UI/87tyll5gfp77YZ6ufCOB0yiJA8EytuzO+rdwY0d4RPcuSBhPm5 +dDTedk+SKlOxJTnbPP/lPqYO5Wue/9vsL3SD3460s6neFE3/MaNFcyT6lSnMEpcE +oji2jbDwN/zIIX8/syQbPYtuzE2wFg2WHYMfRsCbvUOZ58SWLs5fyQ== +-----END CERTIFICATE-----`)) + + // GlobalSign + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEg +MB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2Jh +bFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQx +MjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjET +MBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQssgrRI +xutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1k +ZguSgMpE3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxD +aNc9PIrFsmbVkJq3MQbFvuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJw +LnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqMPKq0pPbzlUoSB239jLKJz9CgYXfIWHSw +1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+azayOeSsJDa38O+2HBNX +k7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05OWgtH8wY2 +SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/h +bguyCLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4n +WUx2OVvq+aWh2IMP0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpY +rZxCRXluDocZXFSxZba/jJvcE+kNb7gu3GduyYsRtYQUigAZcIN5kZeR1Bonvzce +MgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNVHSMEGDAWgBSu +bAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN +nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGt +Ixg93eFyRJa0lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr61 +55wsTLxDKZmOMNOsIeDjHfrYBzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLj +vUYAGm0CuiVdjaExUd1URhxN25mW7xocBFymFe944Hn+Xds+qkxV/ZoVqW/hpvvf +cDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr3TsTjxKM4kEaSHpz +oHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB10jZp +nOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfs +pA9MRf/TuTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+v +JJUEeKgDu+6B5dpffItKoZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R +8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+tJDfLRVpOoERIyNiwmcUVhAn21klJwGW4 +5hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= +-----END CERTIFICATE-----`)) + + // GlobalSign + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4 +MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8 +RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT +gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm +KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd +QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ +XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw +DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o +LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU +RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp +jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK +6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX +mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs +Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH +WD9f +-----END CERTIFICATE-----`)) + + // GlobalSign + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEk +MCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpH +bG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoX +DTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBD +QSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWdu +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6SFkc +8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8ke +hOvRnkmSh5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYI +KoZIzj0EAwMDaAAwZQIxAOVpEslu28YxuglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg +515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7yFz9SO8NdCKoCOJuxUnO +xwy8p2Fp8fc74SrL+SvzZpA3 +-----END CERTIFICATE-----`)) + + // GlobalSign Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG +A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv +b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw +MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i +YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT +aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ +jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp +xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp +1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG +snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ +U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8 +9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B +AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz +yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE +38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP +AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad +DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME +HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== +-----END CERTIFICATE-----`)) + + // GlobalSign Root E46 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx +CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD +ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw +MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex +HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq +R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd +yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ +7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 ++RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= +-----END CERTIFICATE-----`)) + + // GlobalSign Root R46 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUA +MEYxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYD +VQQDExNHbG9iYWxTaWduIFJvb3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMy +MDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt +c2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08EsCVeJ +OaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQG +vGIFAha/r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud +316HCkD7rRlr+/fKYIje2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo +0q3v84RLHIf8E6M6cqJaESvWJ3En7YEtbWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSE +y132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvjK8Cd+RTyG/FWaha/LIWF +zXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD412lPFzYE ++cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCN +I/onccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzs +x2sZy/N78CsHpdlseVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqa +ByFrgY/bxFn63iLABJzjqls2k+g9vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC +4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEMBQADggIBAHx4 +7PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg +JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti +2kM3S+LGteWygxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIk +pnnpHs6i58FZFZ8d4kuaPp92CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRF +FRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZmOUdkLG5NrmJ7v2B0GbhWrJKsFjLt +rWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qqJZ4d16GLuc1CLgSk +ZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwyeqiv5 +u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP +4vkYxboznxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6 +N3ec592kD3ZDZopD8p/7DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3 +vouXsXgxT7PntgMTzlSdriVZzH81Xwj3QEUxeCp6 +-----END CERTIFICATE-----`)) + + // GlobalSign Secure Mail Root E45 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQdlP+qicdlUZd1vGe5biQCjAKBggqhkjOPQQDAzBSMQsw +CQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEoMCYGA1UEAxMf +R2xvYmFsU2lnbiBTZWN1cmUgTWFpbCBSb290IEU0NTAeFw0yMDAzMTgwMDAwMDBa +Fw00NTAzMTgwMDAwMDBaMFIxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxT +aWduIG52LXNhMSgwJgYDVQQDEx9HbG9iYWxTaWduIFNlY3VyZSBNYWlsIFJvb3Qg +RTQ1MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+XmLgUc3iZY/RUlQfxomC5Myfi7A +wKcImsNuj5s+CyLsN1O3b4qwvCc3S22pRjvZH/+loUS7LXO/nkEHXFObUQg6Wrtv +OMcWkXjCShNpHYLfWi8AiJaiLhx0+Z1+ZjeKo0IwQDAOBgNVHQ8BAf8EBAMCAYYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU3xNei1/CQAL9VreUTLYe1aaxFJYw +CgYIKoZIzj0EAwMDaAAwZQIwE7C+13EgPuSrnM42En1fTB8qtWlFM1/TLVqy5IjH +3go2QjJ5naZruuH5RCp7isMSAjEAoGYcToedh8ntmUwbCu4tYMM3xx3NtXKw2cbv +vPL/P/BS3QjnqmR5w+RpV5EvpMt8 +-----END CERTIFICATE-----`)) + + // GlobalSign Secure Mail Root R45 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFcDCCA1igAwIBAgIQdlP+qExQq5+NMrUdA49X3DANBgkqhkiG9w0BAQwFADBS +MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEoMCYGA1UE +AxMfR2xvYmFsU2lnbiBTZWN1cmUgTWFpbCBSb290IFI0NTAeFw0yMDAzMTgwMDAw +MDBaFw00NTAzMTgwMDAwMDBaMFIxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i +YWxTaWduIG52LXNhMSgwJgYDVQQDEx9HbG9iYWxTaWduIFNlY3VyZSBNYWlsIFJv +b3QgUjQ1MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3HnMbQb5bbvg +VgRsf+B1zC0FSehL3FTsW3eVcr9/Yp2FqYokUF9T5dt0b6QpWxMqCa2axS/C93Y7 +oUVGqkPmJP4rsG8ycBlGWnkmL/w9fV9ky1fMYWGo2ZVu45Wgbn9HEhjW7wPJ+4r6 +mr2CFalVd0sRT1nga8Nx8wzYVNWBaD4TuRUuh4o8RCc2YiRu+CwFcjBhvUKRI8Sd +JafZVJoUozGtgHkMp2NsmKOsV0czH2WW4dDSNdr5cfehpiW1QV3fPmDY0fafpfK4 +zBOqj/mybuGDLZPdPoUa3eixXCYBy0mF/PzS1H+FYoZ0+cvsNSKiDDCPO6t561by ++kLz7fkfRYlAKa3qknTqUv1WtCvaou11wm6rzlKQS/be8EmPmkjUiBltRebMjLnd +ZGBgAkD4uc+8WOs9hbnGCtOcB2aPxxg5I0bhPB6jL1Bhkgs9K2zxo0c4V5GrDY/G +nU0E0iZSXOWl/SotFioBaeepfeE2t7Eqxdmxjb25i87Mi6E+C0jNUJU0xNgIWdhr +JvS+9dQiFwBXya6bBDAznwv731aiyW5Udtqxl2InWQ8RiiIbZJY/qPG3JEqNPFN8 +bYN2PbImSHP1RBYBLQkqjhaWUNBzBl27IkiCTApGWj+A/1zy8pqsLAjg1urwEjiB +T6YQ7UarzBacC89kppkChURnRq39TecCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGG +MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKCTFShu7o8IsjXGnmJ5dKexDit7 +MA0GCSqGSIb3DQEBDAUAA4ICAQBFCvjRXKxigdAE17b/V1GJCwzL3iRlN/urnu1m +9OoMGWmJuBmxMFa02fb3vsaul8tF9hGMOjBkTMGfWcBGQggGR2QXeOCVBwbWjKKs +qdk/03tWT/zEhyjftisWI8CfH1vj1kReIk8jBIw1FrV5B4ZcL5fi9ghkptzbqIrj +pHt3DdEpkyggtFOjS05f3sH2dSP8Hzx4T3AxeC+iNVRxBKzIxG3D9pGx/s3uRG6B +9kDFPioBv6tMsQM/DRHkD9Ik4yKIm59fRz1RSeAJN34XITF2t2dxSChLJdcQ6J9h +WRbFPjJOHwzOo8wP5McRByIvOAjdW5frQmxZmpruetCd38XbCUMuCqoZPWvoajB6 +V+a/s2o5qY/j8U9laLa9nyiPoRZaCVA6Mi4dL0QRQqYA5jGY/y2hD+akYFbPedey +Ttew+m4MVyPHzh+lsUxtGUmeDn9wj3E/WCifdd1h4Dq3Obbul9Q1UfuLSWDIPGau +l+6NJllXu3jwelAwCbBgqp9O3Mk+HjrcYpMzsDpUdG8sMUXRaxEyamh29j32ahNe +JJjn6h2az3iCB2D3TRDTgZpFjZ6vm9yAx0OylWikww7oCkcVv1Qz3AHn1aYec9h6 +sr8vreNVMJ7fDkG84BH1oQyoIuHjAKNOcHyS4wTRekKKdZBZ45vRTKJkvXN5m2/y +s8H2PA== +-----END CERTIFICATE-----`)) + + // Go Daddy Class 2 Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEh +MB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBE +YWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3 +MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRo +ZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3Mg +MiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQADggEN +ADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCA +PVYYYwhv2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6w +wdhFJ2+qN1j3hybX2C32qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXi +EqITLdiOr18SPaAIBQi2XKVlOARFmR6jYGB0xUGlcmIbYsUfb18aQr4CUWWoriMY +avx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmYvLEHZ6IVDd2gWMZEewo+ +YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0OBBYEFNLE +sNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h +/t2oatTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5 +IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmlj +YXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD +ggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wimPQoZ+YeAEW5p5JYXMP80kWNy +OO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKtI3lpjbi2Tc7P +TMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ +HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mER +dEr/VxqHD3VILs9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5Cuf +ReYNnyicsbkqWletNw+vHX/bvZ8= +-----END CERTIFICATE-----`)) + + // Go Daddy Root Certificate Authority - G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT +EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp +ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz +NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH +EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE +AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD +E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH +/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy +DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh +GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR +tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA +AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX +WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu +9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr +gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo +2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO +LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI +4uJEvlz36hz1 +-----END CERTIFICATE-----`)) + + // Starfield Class 2 Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzEl +MCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMp +U3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQw +NjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBoMQswCQYDVQQGEwJVUzElMCMGA1UE +ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZp +ZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqGSIb3 +DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf +8MOh2tTYbitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN ++lq2cwQlZut3f+dZxkqZJRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0 +X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVmepsZGD3/cVE8MC5fvj13c7JdBmzDI1aa +K4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSNF4Azbl5KXZnJHoe0nRrA +1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HFMIHCMB0G +A1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fR +zt0fhvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0 +YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBD +bGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8w +DQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGsafPzWdqbAYcaT1epoXkJKtv3 +L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLMPUxA2IGvd56D +eruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl +xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynp +VSJYACPq4xJDKVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEY +WQPJIrSPnNVeKtelttQKbfi3QBFGmh95DmK/D5fs4C8fF5Q= +-----END CERTIFICATE-----`)) + + // Starfield Root Certificate Authority - G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT +HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs +ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw +MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 +b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj +aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp +Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg +nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 +HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N +Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN +dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 +HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G +CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU +sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 +4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg +8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K +pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 +mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 +-----END CERTIFICATE-----`)) + + // GlobalSign + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgINAgPlfvU/k/2lCSGypjAKBggqhkjOPQQDAjBQMSQwIgYD +VQQLExtHbG9iYWxTaWduIEVDQyBSb290IENBIC0gUjQxEzARBgNVBAoTCkdsb2Jh +bFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMTIxMTEzMDAwMDAwWhcNMzgw +MTE5MDMxNDA3WjBQMSQwIgYDVQQLExtHbG9iYWxTaWduIEVDQyBSb290IENBIC0g +UjQxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wWTAT +BgcqhkjOPQIBBggqhkjOPQMBBwNCAAS4xnnTj2wlDp8uORkcA6SumuU5BwkWymOx +uYb4ilfBV85C+nOh92VC/x7BALJucw7/xyHlGKSq2XE/qNS5zowdo0IwQDAOBgNV +HQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/ ++wpu+74zyTyjhNUwCgYIKoZIzj0EAwIDRwAwRAIgIk90crlgr/HmnKAWBVBfw147 +bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm +-----END CERTIFICATE-----`)) + + // GTS Root R1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw +MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp +Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo +27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w +Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw +TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl +qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH +szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8 +Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk +MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92 +wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p +aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN +VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID +AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb +C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe +QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy +h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4 +7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J +ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef +MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/ +Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT +6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ +0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm +2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb +bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c +-----END CERTIFICATE-----`)) + + // GTS Root R2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw +MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp +Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt +nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY +6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu +MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k +RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg +f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV ++3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo +dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW +Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa +G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq +gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID +AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H +vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 +0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC +B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u +NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg +yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev +HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6 +xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR +TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg +JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV +7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl +6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL +-----END CERTIFICATE-----`)) + + // GTS Root R3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G +jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2 +4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7 +VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm +ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X +-----END CERTIFICATE-----`)) + + // GTS Root R4 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi +QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR +HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 +p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE-----`)) + + // Hongkong Post Root CA 3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL +BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ +SG9uZyBLb25nMRYwFAYDVQQKEw1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25n +a29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2MDMwMjI5NDZaFw00MjA2MDMwMjI5 +NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtvbmcxEjAQBgNVBAcT +CUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMXSG9u +Z2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCziNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFO +dem1p+/l6TWZ5Mwc50tfjTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mI +VoBc+L0sPOFMV4i707mV78vH9toxdCim5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV +9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOesL4jpNrcyCse2m5FHomY +2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj0mRiikKY +vLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+Tt +bNe/JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZb +x39ri1UbSsUgYT2uy1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+ +l2oBlKN8W4UdKjk60FSh0Tlxnf0h+bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YK +TE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsGxVd7GYYKecsAyVKvQv83j+Gj +Hno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwIDAQABo2MwYTAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e +i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEw +DQYJKoZIhvcNAQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG +7BJ8dNVI0lkUmcDrudHr9EgwW62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCk +MpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWldy8joRTnU+kLBEUx3XZL7av9YROXr +gZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov+BS5gLNdTaqX4fnk +GMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDceqFS +3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJm +Ozj/2ZQw9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+ +l6mc1X5VTMbeRRAc6uk7nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6c +JfTzPV4e0hz5sy229zdcxsshTrD3mUcYhcErulWuBurQB7Lcq9CClnXO0lD+mefP +L5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB60PZ2Pierc+xYw5F9KBa +LJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fqdBb9HxEG +mpv0 +-----END CERTIFICATE-----`)) + + // ACCVRAIZ1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE +AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw +CQYDVQQGEwJFUzAeFw0xMTA1MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQ +BgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwHUEtJQUNDVjENMAsGA1UECgwEQUND +VjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCb +qau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gMjmoY +HtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWo +G2ioPej0RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpA +lHPrzg5XPAOBOp0KoVdDaaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhr +IA8wKFSVf+DuzgpmndFALW4ir50awQUZ0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/ +0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDGWuzndN9wrqODJerWx5eH +k6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs78yM2x/47 +4KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMO +m3WR5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpa +cXpkatcnYGMN285J9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPl +uUsXQA+xtrn13k/c4LOsOxFwYIRKQ26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYI +KwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRwOi8vd3d3LmFjY3YuZXMvZmls +ZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEuY3J0MB8GCCsG +AQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 +VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeT +VfZW6oHlNsyMHj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIG +CCsGAQUFBwICMIIBFB6CARAAQQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUA +cgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBhAO0AegAgAGQAZQAgAGwAYQAgAEEA +QwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUAYwBuAG8AbABvAGcA +7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBjAHQA +cgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAA +QwBQAFMAIABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUA +czAwBggrBgEFBQcCARYkaHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2Mu +aHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRt +aW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2MV9kZXIuY3JsMA4GA1Ud +DwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZIhvcNAQEF +BQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdp +D70ER9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gU +JyCpZET/LtZ1qmxNYEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+m +AM/EKXMRNt6GGT6d7hmKG9Ww7Y49nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepD +vV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJTS+xJlsndQAJxGJ3KQhfnlms +tn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3sCPdK6jT2iWH +7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h +I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szA +h1xA2syVP1XgNce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xF +d3+YJ5oyXSrjhO7FmGYvliAd3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2H +pPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3pEfbRD0tVNEYqi4Y7 +-----END CERTIFICATE-----`)) + + // AC RAIZ FNMT-RCM + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsx +CzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJ +WiBGTk1ULVJDTTAeFw0wODEwMjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJ +BgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBG +Tk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALpxgHpMhm5/ +yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcfqQgf +BBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAz +WHFctPVrbtQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxF +tBDXaEAUwED653cXeuYLj2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z +374jNUUeAlz+taibmSXaXvMiwzn15Cou08YfxGyqxRxqAQVKL9LFwag0Jl1mpdIC +IfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mwWsXmo8RZZUc1g16p6DUL +mbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnTtOmlcYF7 +wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peS +MKGJ47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2 +ZSysV4999AeU14ECll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMet +UqIJ5G+GR4of6ygnXYMgrwTJbFaai0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFPd9xf3E6Jobd2Sn9R2gzL+H +YJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1odHRwOi8vd3d3 +LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD +nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1 +RXxlDPiyN8+sD8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYM +LVN0V2Ue1bLdI4E7pWYjJ2cJj+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf +77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrTQfv6MooqtyuGC2mDOL7Nii4LcK2N +JpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW+YJF1DngoABd15jm +fZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7Ixjp +6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp +1txyM/1d8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B +9kiABdcPUXmsEKvU7ANm5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wok +RqEIr9baRRmW1FMdW4R58MD3R++Lj8UGrp1MYp3/RgT408m2ECVAdf4WqslKYIYv +uu8wd+RU4riEmViAqhOLUTpPSPaLtrM= +-----END CERTIFICATE-----`)) + + // AC RAIZ FNMT-RCM SERVIDORES SEGUROS + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICbjCCAfOgAwIBAgIQYvYybOXE42hcG2LdnC6dlTAKBggqhkjOPQQDAzB4MQsw +CQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVzMRgw +FgYDVQRhDA9WQVRFUy1RMjgyNjAwNEoxLDAqBgNVBAMMI0FDIFJBSVogRk5NVC1S +Q00gU0VSVklET1JFUyBTRUdVUk9TMB4XDTE4MTIyMDA5MzczM1oXDTQzMTIyMDA5 +MzczM1oweDELMAkGA1UEBhMCRVMxETAPBgNVBAoMCEZOTVQtUkNNMQ4wDAYDVQQL +DAVDZXJlczEYMBYGA1UEYQwPVkFURVMtUTI4MjYwMDRKMSwwKgYDVQQDDCNBQyBS +QUlaIEZOTVQtUkNNIFNFUlZJRE9SRVMgU0VHVVJPUzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABPa6V1PIyqvfNkpSIeSX0oNnnvBlUdBeh8dHsVnyV0ebAAKTRBdp20LH +sbI6GA60XYyzZl2hNPk2LEnb80b8s0RpRBNm/dfF/a82Tc4DTQdxz69qBdKiQ1oK +Um8BA06Oi6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD +VR0OBBYEFAG5L++/EYZg8k/QQW6rcx/n0m5JMAoGCCqGSM49BAMDA2kAMGYCMQCu +SuMrQMN0EfKVrRYj3k4MGuZdpSRea0R7/DjiT8ucRRcRTBQnJlU5dUoDzBOQn5IC +MQD6SmxgiHPz7riYYqnOK8LZiqZwMR2vsJRM60/G49HzYqc8/5MuB1xJAWdpEgJy +v+c= +-----END CERTIFICATE-----`)) + + // Staat der Nederlanden Root CA - G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIEAJiiOTANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJO +TDEeMBwGA1UECgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSswKQYDVQQDDCJTdGFh +dCBkZXIgTmVkZXJsYW5kZW4gUm9vdCBDQSAtIEczMB4XDTEzMTExNDExMjg0MloX +DTI4MTExMzIzMDAwMFowWjELMAkGA1UEBhMCTkwxHjAcBgNVBAoMFVN0YWF0IGRl +ciBOZWRlcmxhbmRlbjErMCkGA1UEAwwiU3RhYXQgZGVyIE5lZGVybGFuZGVuIFJv +b3QgQ0EgLSBHMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL4yolQP +cPssXFnrbMSkUeiFKrPMSjTysF/zDsccPVMeiAho2G89rcKezIJnByeHaHE6n3WW +IkYFsO2tx1ueKt6c/DrGlaf1F2cY5y9JCAxcz+bMNO14+1Cx3Gsy8KL+tjzk7FqX +xz8ecAgwoNzFs21v0IJyEavSgWhZghe3eJJg+szeP4TrjTgzkApyI/o1zCZxMdFy +KJLZWyNtZrVtB0LrpjPOktvA9mxjeM3KTj215VKb8b475lRgsGYeCasH/lSJEULR +9yS6YHgamPfJEf0WwTUaVHXvQ9Plrk7O53vDxk5hUUurmkVLoR9BvUhTFXFkC4az +5S6+zqQbwSmEorXLCCN2QyIkHxcE1G6cxvx/K2Ya7Irl1s9N9WMJtxU51nus6+N8 +6U78dULI7ViVDAZCopz35HCz33JvWjdAidiFpNfxC95DGdRKWCyMijmev4SH8RY7 +Ngzp07TKbBlBUgmhHbBqv4LvcFEhMtwFdozL92TkA1CvjJFnq8Xy7ljY3r735zHP +bMk7ccHViLVlvMDoFxcHErVc0qsgk7TmgoNwNsXNo42ti+yjwUOH5kPiNL6VizXt +BznaqB16nzaeErAMZRKQFWDZJkBE41ZgpRDUajz9QdwOWke275dhdU/Z/seyHdTt +XUmzqWrLZoQT1Vyg3N9udwbRcXXIV2+vD3dbAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRUrfrHkleuyjWcLhL75Lpd +INyUVzANBgkqhkiG9w0BAQsFAAOCAgEAMJmdBTLIXg47mAE6iqTnB/d6+Oea31BD +U5cqPco8R5gu4RV78ZLzYdqQJRZlwJ9UXQ4DO1t3ApyEtg2YXzTdO2PCwyiBwpwp +LiniyMMB8jPqKqrMCQj3ZWfGzd/TtiunvczRDnBfuCPRy5FOCvTIeuXZYzbB1N/8 +Ipf3YF3qKS9Ysr1YvY2WTxB1v0h7PVGHoTx0IsL8B3+A3MSs/mrBcDCw6Y5p4ixp +gZQJut3+TcCDjJRYwEYgr5wfAvg1VUkvRtTA8KCWAg8zxXHzniN9lLf9OtMJgwYh +/WA9rjLA0u6NpvDntIJ8CsxwyXmA+P5M9zWEGYox+wrZ13+b8KKaa8MFSu1BYBQw +0aoRQm7TIwIEC8Zl3d1Sd9qBa7Ko+gE4uZbqKmxnl4mUnrzhVNXkanjvSr0rmj1A +fsbAddJu+2gw7OyLnflJNZoaLNmzlTnVHpL3prllL+U9bTpITAjc5CgSKL59NVzq +4BZ+Extq1z7XnvwtdbLBFNUjA9tbbws+eC8N3jONFrdI54OagQ97wUNNVQQXOEpR +1VmiiXTTn74eS9fGbbeIJG9gkaSChVtWQbzQRKtqE77RLFi3EjNYsjdj3BP1lB0/ +QFH1T/U67cjF68IeHRaVesd+QnGTbksVtzDfqu1XhUisHWrdOWnk4Xl4vs4Fv6EM +94B7IWcnMFk= +-----END CERTIFICATE-----`)) + + // TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIx +GDAWBgNVBAcTD0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxp +bXNlbCB2ZSBUZWtub2xvamlrIEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0w +KwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24gTWVya2V6aSAtIEthbXUgU00xNjA0 +BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRpZmlrYXNpIC0gU3Vy +dW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYDVQQG +EwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXll +IEJpbGltc2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklU +QUsxLTArBgNVBAsTJEthbXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBT +TTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11IFNNIFNTTCBLb2sgU2VydGlmaWthc2kg +LSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr3UwM6q7 +a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y86Ij5iySr +LqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INr +N3wcwv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2X +YacQuFWQfw4tJzh03+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/ +iSIzL+aFCr2lqBs23tPcLG07xxO9WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4f +AJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQUZT/HiobGPN08VFw1+DrtUgxH +V8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh +AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPf +IPP54+M638yclNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4 +lzwDGrpDxpa5RXI4s6ehlj2Re37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c +8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0jq5Rm+K37DwhuJi1/FwcJsoz7UMCf +lo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= +-----END CERTIFICATE-----`)) + + // HARICA Client ECC Root CA 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICWjCCAeGgAwIBAgIQMWjZ2OFiVx7SGUSI5hB98DAKBggqhkjOPQQDAzBvMQsw +CQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2Vh +cmNoIEluc3RpdHV0aW9ucyBDQTEnMCUGA1UEAwweSEFSSUNBIENsaWVudCBFQ0Mg +Um9vdCBDQSAyMDIxMB4XDTIxMDIxOTExMDMzNFoXDTQ1MDIxMzExMDMzM1owbzEL +MAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl +YXJjaCBJbnN0aXR1dGlvbnMgQ0ExJzAlBgNVBAMMHkhBUklDQSBDbGllbnQgRUND +IFJvb3QgQ0EgMjAyMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABAcYrZWWlNBcD4L3 +KkD6AsnJPTamowRqwW2VAYhgElRsXKIrbhM6iJUMHCaGNkqJGbcY3jvoqFAfyt9b +v0mAFdvjMOEdWscqigEH/m0sNO8oKJe8wflXhpWLNc+eWtFolaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUUgjSvjKBJf31GpfsTl8au1PNkK0wDgYDVR0P +AQH/BAQDAgGGMAoGCCqGSM49BAMDA2cAMGQCMEwxRUZPqOa+w3eyGhhLLYh7WOar +lGtEA7AX/9+Cc0RRLP2THQZ7FNKJ7EAM7yEBLgIwL8kuWmwsHdmV4J6wuVxSfPb4 +OMou8dQd8qJJopX4wVheT/5zCu8xsKsjWBOMi947 +-----END CERTIFICATE-----`)) + + // HARICA Client RSA Root CA 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFqjCCA5KgAwIBAgIQVVL4HtsbJCyeu5YYzQIoPjANBgkqhkiG9w0BAQsFADBv +MQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl +c2VhcmNoIEluc3RpdHV0aW9ucyBDQTEnMCUGA1UEAwweSEFSSUNBIENsaWVudCBS +U0EgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTEwNTg0NloXDTQ1MDIxMzEwNTg0NVow +bzELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBS +ZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJzAlBgNVBAMMHkhBUklDQSBDbGllbnQg +UlNBIFJvb3QgQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +AIHbV0KQLHQ19Pi4dBlNqwlad0WBc2KwNZ/40LczAIcTtparDlQSMAe8m7dI19EZ +g66O2KnxqQCEsIxenugMj1Rpv/bUCE8mcP4YQWMaszKLQPgHq1cx8MYWdmeatN0v +8tFrxdCShJFxbg8uY+kfU6TdUhPMCYMpgQzFU3VEsQ5nUxjQwx+IS5+UJLQpvLvo +Tv1v0hUdSdyNcPIRGiBRVRG6iG/E91B51qox4oQ9XjLIdypQceULL+m26u+rCjM5 +Dv2PpWdDgo6YaQkJG0DNOGdH6snsl3ES3iT1cjzR90NMJveQsonpRUtVPTEFekHi +lbpDwBfFtoU9GY1kcPNbrM2f0yl1h0uVZ2qm+NHdvJCGiUMpqTdb9V2wJlpTQnaQ +K8+eVmwrVM9cmmXfW4tIYDh8+8ULz3YEYwIzKn31g2fn+sZD/SsP1CYvd6QywSTq +ZJ2/szhxMUTyR7iiZkGh+5t7vMdGanW/WqKM6GpEwbiWtcAyCC17dDVzssrG/q8R +chj258jCz6Uq6nvWWeh8oLJqQAlpDqWW29EAufGIbjbwiLKd8VLyw3y/MIk8Cmn5 +IqRl4ZvgdMaxhZeWLK6Uj1CmORIfvkfygXjTdTaefVogl+JSrpmfxnybZvP+2M/u +vZcGHS2F3D42U5Z7ILroyOGtlmI+EXyzAISep0xxq0o3AgMBAAGjQjBAMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFKDWBz1eJPd7oEQuJFINGaorBJGnMA4GA1Ud +DwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEADUf5CWYxUux57sKo8mg+7ZZF +yzqmmGM/6itNTgPQHILhy9Pl1qtbZyi8nf4MmQqAVafOGyNhDbBX8P7gyr7mkNuD +LL6DjvR5tv7QDUKnWB9p6oH1BaX+RmjrbHjJ4Orn5t4xxdLVLIJjKJ1dqBp+iObn +K/Es1dAFntwtvTdm1ASip62/OsKoO63/jZ0z4LmahKGHH3b0gnTXDvkwSD5biD6q +XGvWLwzojnPCGJGDObZmWtAfYCddTeP2Og1mUJx4e6vzExCuDy+r6GSzGCCdRjVk +JXPqmxBcWDWJsUZIp/Ss1B2eW8yppRoTTyRQqtkbbbFA+53dWHTEwm8UcuzbNZ+4 +VHVFw6bIGig1Oq5l8qmYzq9byTiMMTt/zNyW/eJb1tBZ9Ha6C8tPgxDHQNAdYOkq +5UhYdwxFab4ZcQQk4uMkH0rIwT6Z9ZaYOEgloRWwG9fihBhb9nE1mmh7QMwYXAwk +ndSV9ZmqRuqurL/0FBkk6Izs4/W8BmiKKgwFXwqXdafcfsD913oY3zDROEsfsJhw +v8x8c/BuxDGlpJcdrL/ObCFKvicjZ/MGVoEKkY624QMFMyzaNAhNTlAjrR+lxdR6 +/uoJ7KcoYItGfLXqm91P+edrFcaIz0Pb5SfcBFZub0YV8VYt6FwMc8MjgTggy8kM +ac8sqzuEYDMZUv1pFDM= +-----END CERTIFICATE-----`)) + + // HARICA TLS ECC Root CA 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICVDCCAdugAwIBAgIQZ3SdjXfYO2rbIvT/WeK/zjAKBggqhkjOPQQDAzBsMQsw +CQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2Vh +cmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBFQ0MgUm9v +dCBDQSAyMDIxMB4XDTIxMDIxOTExMDExMFoXDTQ1MDIxMzExMDEwOVowbDELMAkG +A1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj +aCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgRUNDIFJvb3Qg +Q0EgMjAyMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDgI/rGgltJ6rK9JOtDA4MM7 +KKrxcm1lAEeIhPyaJmuqS7psBAqIXhfyVYf8MLA04jRYVxqEU+kw2anylnTDUR9Y +STHMmE5gEYd103KUkE+bECUqqHgtvpBBWJAVcqeht6NCMEAwDwYDVR0TAQH/BAUw +AwEB/zAdBgNVHQ4EFgQUyRtTgRL+BNUW0aq8mm+3oJUZbsowDgYDVR0PAQH/BAQD +AgGGMAoGCCqGSM49BAMDA2cAMGQCMBHervjcToiwqfAircJRQO9gcS3ujwLEXQNw +SaSS6sUUiHCm0w2wqsosQJz76YJumgIwK0eaB8bRwoF8yguWGEEbo/QwCZ61IygN +nxS2PFOiTAZpffpskcYqSUXm7LcT4Tps +-----END CERTIFICATE-----`)) + + // HARICA TLS RSA Root CA 2021 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBs +MQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl +c2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0Eg +Um9vdCBDQSAyMDIxMB4XDTIxMDIxOTEwNTUzOFoXDTQ1MDIxMzEwNTUzN1owbDEL +MAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl +YXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNBIFJv +b3QgQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569l +mwVnlskNJLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FHws+RFjZiPTgE +4VGC/6zStGndLuwRo0Xua2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uv +a9of08WRiFukiZLRgeaMOVig1mlDqa2YUlhu2wr7a89o+uOkXjpFc5gH6l8Cct4M +pbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K5FrZx40d/JiZ+yykgmvw +Kh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEvdmn8kN3b +LW7R8pv1GmuebxWMevBLKKAiOIAkbDakO/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcY +AuUR0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqB +AGMUuTNe3QvboEUHGjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYq +E613TBoYm5EPWNgGVMWX+Ko/IIqmhaZ39qb8HOLubpQzKoNQhArlT4b4UEV4AIHr +W2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQCPxrvrNQKlr9qEgYRtaQQJKQ +CoReaDH46+0N0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLuMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAU +X15QvWiWkKQUEapobQk1OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3 +f5Z2EMVGpdAgS1D0NTsY9FVqQRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar5AWMxaja +H6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxDQpSbIPDRzbLrLFPCU3hKTwSUQZqP +JzLB5UkZv/HywouoCjkxKLR9YjYsTewfM7Z+d21+UPCfDtcRj88YxeMn/ibvBZ3P +zzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5vZSt +jBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0 +/L5H9MG0qPzVMIho7suuyWPEdr6sOBjhXlzPrjoiUevRi7PzKzMHVIf6tLITe7pT +BGIBnfHAT+7hOtSLIBD6Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79 +aPib8qXPMThcFarmlwDB31qlpzmq6YR/PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YW +xw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnnkf3/W9b3raYvAwtt41dU +63ZTGI0RmLo= +-----END CERTIFICATE-----`)) + + // Hellenic Academic and Research Institutions ECC RootCA 2015 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzAN +BgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl +c2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hl +bGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgRUNDIFJv +b3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEwMzcxMlowgaoxCzAJ +BgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmljIEFj +YWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5 +MUQwQgYDVQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0 +dXRpb25zIEVDQyBSb290Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKg +QehLgoRc4vgxEZmGZE4JJS+dQS8KrjVPdJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJa +jq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoKVlp8aQuqgAkkbH7BRqNC +MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFLQi +C4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaep +lSTAGiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7Sof +TUwJCA3sS61kFyjndc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR +-----END CERTIFICATE-----`)) + + // Hellenic Academic and Research Institutions RootCA 2015 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1Ix +DzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5k +IFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMT +N0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9v +dENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAxMTIxWjCBpjELMAkG +A1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNh +ZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkx +QDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1 +dGlvbnMgUm9vdENBIDIwMTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC +AQDC+Kk/G4n8PDwEXT2QNrCROnk8ZlrvbTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA +4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+ehiGsxr/CL0BgzuNtFajT0 +AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+6PAQZe10 +4S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06C +ojXdFPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV +9Cz82XBST3i4vTwri5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrD +gfgXy5I2XdGj2HUb4Ysn6npIQf1FGQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6 +Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2fu/Z8VFRfS0myGlZYeCsargq +NhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9muiNX6hME6wGko +LfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc +Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNV +HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVd +ctA4GGqd83EkVAswDQYJKoZIhvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0I +XtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+D1hYc2Ryx+hFjtyp8iY/xnmMsVMI +M4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrMd/K4kPFox/la/vot +9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+yd+2V +Z5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/ea +j8GsGsVn82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnh +X9izjFk0WaSrT2y7HxjbdavYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQ +l033DlZdwJVqwjbDG2jJ9SrcR5q+ss7FJej6A7na+RZukYT1HCjI/CbM1xyQVqdf +bzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVtJ94Cj8rDtSvK6evIIVM4 +pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGaJI7ZjnHK +e7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0 +vm9qp/UsQu0yrbYhnr68 +-----END CERTIFICATE-----`)) + + // IdenTrust Commercial Root CA 1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBK +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVu +VHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQw +MTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScw +JQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ldhNlT +3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU ++ehcCuz/mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gp +S0l4PJNgiCL8mdo2yMKi1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1 +bVoE/c40yiTcdCMbXTMTEl3EASX2MN0CXZ/g1Ue9tOsbobtJSdifWwLziuQkkORi +T0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl3ZBWzvurpWCdxJ35UrCL +vYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzyNeVJSQjK +Vsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZK +dHzVWYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHT +c+XvvqDtMwt0viAgxGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hv +l7yTmvmcEpB4eoCHFddydJxVdHixuuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5N +iGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZIhvcNAQELBQAD +ggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH +6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwt +LRvM7Kqas6pgghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93 +nAbowacYXVKV7cndJZ5t+qntozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3 ++wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmVYjzlVYA211QC//G5Xc7UI2/YRYRK +W2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUXfeu+h1sXIFRRk0pT +AwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/rokTLq +l1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG +4iZZRHUe2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZ +mUlO+KWA2yUPHGNiiskzZ2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A +7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7RcGzM7vRX+Bi6hG6H +-----END CERTIFICATE-----`)) + + // IdenTrust Public Sector Root CA 1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBN +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVu +VHJ1c3QgUHVibGljIFNlY3RvciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcN +MzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0 +MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBSb290IENBIDEwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTyP4o7 +ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGy +RBb06tD6Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlS +bdsHyo+1W/CD80/HLaXIrcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF +/YTLNiCBWS2ab21ISGHKTN9T0a9SvESfqy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R +3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoSmJxZZoY+rfGwyj4GD3vw +EUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFnol57plzy +9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9V +GxyhLrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ +2fjXctscvG29ZV/viDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsV +WaFHVCkugyhfHMKiq3IXAAaOReyL4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gD +W/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMwDQYJKoZIhvcN +AQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj +t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHV +DRDtfULAj+7AmgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9 +TaDKQGXSc3z1i9kKlT/YPyNtGtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8G +lwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFtm6/n6J91eEyrRjuazr8FGF1NFTwW +mhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMxNRF4eKLg6TCMf4Df +WN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4Mhn5 ++bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJ +tshquDDIajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhA +GaQdp/lLQzfcaFpPz+vCZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv +8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ3Wl9af0AVqW3rLatt8o+Ae+c +-----END CERTIFICATE-----`)) + + // ISRG Root X1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE-----`)) + + // ISRG Root X2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw +CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg +R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00 +MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT +ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW ++1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9 +ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI +zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW +tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 +/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE-----`)) + + // Izenpe.com + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4 +MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6 +ZW5wZS5jb20wHhcNMDcxMjEzMTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYD +VQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5j +b20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ03rKDx6sp4boFmVq +scIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAKClaO +xdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6H +LmYRY2xU+zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFX +uaOKmMPsOzTFlUFpfnXCPCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQD +yCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxTOTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+ +JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbKF7jJeodWLBoBHmy+E60Q +rLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK0GqfvEyN +BjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8L +hij+0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIB +QFqNeb+Lz0vPqhbBleStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+ +HMh3/1uaD7euBUbl8agW7EekFwIDAQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2lu +Zm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+SVpFTlBFIFMuQS4gLSBDSUYg +QTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBGNjIgUzgxQzBB +BgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx +MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUA +A4ICAQB4pgwWSp9MiDrAyw6lFn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWb +laQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbgakEyrkgPH7UIBzg/YsfqikuFgba56 +awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8qhT/AQKM6WfxZSzwo +JNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Csg1lw +LDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCT +VyvehQP5aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGk +LhObNA5me0mrZJfQRsN5nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJb +UjWumDqtujWTI6cfSN01RpiyEGjkpTHCClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/ +QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZoQ0iy2+tzJOeRf1SktoA+ +naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1ZWrOZyGls +QyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== +-----END CERTIFICATE-----`)) + + // SZAFIR ROOT CA2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQEL +BQAwUTELMAkGA1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6 +ZW5pb3dhIFMuQS4xGDAWBgNVBAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkw +NzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJBgNVBAYTAlBMMSgwJgYDVQQKDB9L +cmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYDVQQDDA9TWkFGSVIg +Uk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5QqEvN +QLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT +3PSQ1hNKDJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw +3gAeqDRHu5rr/gsUvTaE2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr6 +3fE9biCloBK0TXC5ztdyO4mTp4CEHCdJckm1/zuVnsHMyAHs6A6KCpbns6aH5db5 +BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwiieDhZNRnvDF5YTy7ykHN +XGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsF +AAOCAQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw +8PRBEew/R40/cof5O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOG +nXkZ7/e7DDWQw4rtTw/1zBLZpD67oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCP +oky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul4+vJhaAlIDf7js4MNIThPIGy +d05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6+/NNIxuZMzSg +LvWpCz/UXeHPhJ/iGcJfitYgHuNztw== +-----END CERTIFICATE-----`)) + + // LAWtrust Root CA2 (4096) + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFmDCCA4CgAwIBAgIEVRpusTANBgkqhkiG9w0BAQsFADBDMQswCQYDVQQGEwJa +QTERMA8GA1UEChMITEFXdHJ1c3QxITAfBgNVBAMTGExBV3RydXN0IFJvb3QgQ0Ey +ICg0MDk2KTAgFw0yMzAyMTQwOTE5MzhaGA8yMDUzMDIxNDA5NDkzOFowQzELMAkG +A1UEBhMCWkExETAPBgNVBAoTCExBV3RydXN0MSEwHwYDVQQDExhMQVd0cnVzdCBS +b290IENBMiAoNDA5NikwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDM +F8srQ7ps+cmTimUNEkzsJxS3E3ng1NUtGFbx+eoqEBZObETHamVG85qJNdGH+DOJ +L4gJGpIQkZDBa58Obn8mihNdGKxoAQ0QeGVw2I6PhFqXMBjQEQ5KjVIQpYErUSj1 +Y8S27ECzAeWtd73lOO+8jbPdGaB7DY2022r7JTNa+pGvxHFFMPiIKXvLv9W6JwSO +3bIA98pcmTUU6v11BhUIu8pXaPs/+7Q0c2PR1ePIOFppfWp6RAwNik7tkh0Qjzsi +LLbf7cXG8Il5VGVeXxu9j33fubft6+TFB9FnPJU7kf5CelJAgATSOVdL9JJ9/5vv +5Z3JCbKREjimKQg7ruvKzO1N504hAQf8bzLOaYyEUsZ36icwCt6lrzAraB+s1Owh +rSJJds4PwvIHKvlqEoOaOwSuGXr+oYYk+kFeJXxArCe24yk2bzXiV9AZWN//ZPbD +AUl22yu+vLlPFArVG1gh9hwuAHz4lLXLNxoU5DK5FtRg7AWqXzL6aiMSrNQQu9Ki +grRLDotwJ6rWB8FniPqEwwjJioTI0jdygQ+NFkrk1zVRpTgPjIRLlTbA9ded4F2P +q5HuAAi5nVIf7PiZu3lWsUna0uXYYYtbr/CrN8V7Go6Gvn7FexUeYWjoC4eLc0mh +F3N+KXiOyuBBL3VzdKKXOn/3LnQJuExgi0Y2GRAtnQIDAQABo4GRMIGOMA8GA1Ud +EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMCsGA1UdEAQkMCKADzIwMjMwMjE0 +MDkxOTM4WoEPMjA1MzAyMTQwOTQ5MzhaMB8GA1UdIwQYMBaAFNfWVmJcPxeB5nNE +KfVRBe8LYDesMB0GA1UdDgQWBBTX1lZiXD8XgeZzRCn1UQXvC2A3rDANBgkqhkiG +9w0BAQsFAAOCAgEASZwp/j3snkV/qz48/iNvNz53p1P/eJ/8SUSAV2acbtp5/81F +rUyTv7VZxukQt+X4jPuHxR6L2LM/ApYKu4qO79e0wIMgOJdZRWT89ncT8gnXocg4 +dAjq+UhM+h8EnLT/7G5WNnKTbJU+LF/eDwurycwVPhaPZvyyELih0bTewGMZzO9T +qnU2IoslH7+byNfBX+ymNwmqe2K89iIt8dZY3Yy7UvQLp3apensajdytmoFiLoYF +kHJHL6HJZ4SwDWywuJsWt9CZFC+cEpsjqI2mQx7p5S3leKcfZJRktneyqFz7Casp +6x5tddH20MWlwx2fHvMaLbLIH+UoCm7zX/3a5iOhdpBcS5gBgizuRy0CGl9/NMVp +tXKtPvPPnm34KegRJyvgWQsbYetKymmlpNXNURuUjnnN3/audF2xLBuGU/7RMAZB +NAdigkz0fseHdA6wIR4JIIDBsxU9Rm3T8QaSP++glYocbncxtut4KQx77oKlT36k +KV6eqi34jsDz/A0GhZtO3PfiCXzQFFEeerMjr/rRYSpltQHZuOMHyiR20vBKvu+G +BIBCFXARaH7Xx7v+506bnJWlHEqkydAJjKrOSNIekpfXEentZsw33PXXG3SbpupC +rF0y4Fj0gUf/0hLifhzcSXaWwx2fS8pcKjdbPYrROJsh2uO/RUPT4Fh3Hyg= +-----END CERTIFICATE-----`)) + + // e-Szigno Root CA 2017 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNV +BAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRk +LjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJv +b3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZaFw00MjA4MjIxMjA3MDZaMHExCzAJ +BgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMg +THRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25v +IFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtv +xie+RJCxs1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+H +Wyx7xf58etqjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBSHERUI0arBeAyxr87GyZDvvzAEwDAfBgNVHSMEGDAWgBSHERUI0arB +eAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEAtVfd14pVCzbhhkT61Nlo +jbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxOsvxyqltZ ++efcMQ== +-----END CERTIFICATE-----`)) + + // Microsec e-Szigno Root CA 2009 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYD +VQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0 +ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0G +CSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTAeFw0wOTA2MTYxMTMwMThaFw0y +OTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3Qx +FjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUtU3pp +Z25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o +dTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvP +kd6mJviZpWNwrZuuyjNAfW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tc +cbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG0IMZfcChEhyVbUr02MelTTMuhTlAdX4U +fIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKApxn1ntxVUwOXewdI/5n7 +N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm1HxdrtbC +xkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1 ++rUCAwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPM +Pcu1SCOhGnqmKrs0aDAbBgNVHREEFDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqG +SIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0olZMEyL/azXm4Q5DwpL7v8u8h +mLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfXI/OMn74dseGk +ddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 +tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c +2Pm2G2JwCz02yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5t +HMN1Rq41Bab2XD0h7lbwyYIiLXpUq3DDfSJlgnCW +-----END CERTIFICATE-----`)) + + // Microsoft ECC Root Certificate Authority 2017 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQsw +CQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYD +VQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIw +MTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4MjMxNjA0WjBlMQswCQYDVQQGEwJV +UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNy +b3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZR +ogPZnZH6thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYb +hGBKia/teQ87zvH2RPUBeMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBTIy5lycFIM+Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3 +FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlfXu5gKcs68tvWMoQZP3zV +L8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaReNtUjGUB +iudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M= +-----END CERTIFICATE-----`)) + + // Microsoft RSA Root Certificate Authority 2017 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBl +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw +NAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 +IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQG +EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1N +aWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZ +Nt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0 +ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1 +HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztm +gGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XFWvLJ +jEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUc +aDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaG +YaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6 +W6IYZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4K +UGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH ++FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+Q +W5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZC +LgLNFgVZJ8og6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OC +gMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6 +tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nh +SnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2 +TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3 +pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGR +xpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiApp +GWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9 +dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hN +AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB +RA+GsCyRxj3qrg+E +-----END CERTIFICATE-----`)) + + // NAVER Global Root Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEM +BQAwaTELMAkGA1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRG +T1JNIENvcnAuMTIwMAYDVQQDDClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4NDJaFw0zNzA4MTgyMzU5NTlaMGkx +CzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVTUyBQTEFURk9STSBD +b3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVA +iQqrDZBbUGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH +38dq6SZeWYp34+hInDEW+j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lE +HoSTGEq0n+USZGnQJoViAbbJAh2+g1G7XNr4rRVqmfeSVPc0W+m/6imBEtRTkZaz +kVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2aacp+yPOiNgSnABIqKYP +szuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4Yb8Obtoq +vC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHf +nZ3zVHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaG +YQ5fG8Ir4ozVu53BA0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo +0es+nPxdGoMuK8u180SdOqcXYZaicdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3a +CJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejyYhbLgGvtPe31HzClrkvJE+2K +AQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNVHQ4EFgQU0p+I +36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoN +qo0hV4/GPnrK21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatj +cu3cvuzHV+YwIHHW1xDBE1UBjCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm ++LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bxhYTeodoS76TiEJd6eN4MUZeoIUCL +hr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTgE34h5prCy8VCZLQe +lHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTHD8z7 +p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8 +piKCk5XQA76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLR +LBT/DShycpWbXgnbiUSYqqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX +5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oGI/hGoiLtk/bdmuYqh7GYVPEi92tF4+KO +dh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmgkpzNNIaRkPpkUZ3+/uul +9XXeifdy +-----END CERTIFICATE-----`)) + + // NetLock Arany (Class Gold) Főtanúsítvány + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQG +EwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3 +MDUGA1UECwwuVGFuw7pzw610dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNl +cnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBBcmFueSAoQ2xhc3MgR29sZCkgRsWR +dGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgxMjA2MTUwODIxWjCB +pzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxOZXRM +b2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlm +aWNhdGlvbiBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNz +IEdvbGQpIEbFkXRhbsO6c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAxCRec75LbRTDofTjl5Bu0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrT +lF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw/HpYzY6b7cNGbIRwXdrz +AZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAkH3B5r9s5 +VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRG +ILdwfzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2 +BJtr+UBdADTHLpl1neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAG +AQH/AgEEMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2M +U9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwWqZw8UQCgwBEIBaeZ5m8BiFRh +bvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTtaYtOUZcTh5m2C ++C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC +bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2F +uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2 +XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= +-----END CERTIFICATE-----`)) + + // OISTE Client Root ECC G1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICNDCCAbqgAwIBAgIQVOyX1ou0xAshbg6y0FPIejAKBggqhkjOPQQDAzBLMQsw +CQYDVQQGEwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwY +T0lTVEUgQ2xpZW50IFJvb3QgRUNDIEcxMB4XDTIzMDUzMTE0MzE0MFoXDTQ4MDUy +NDE0MzEzOVowSzELMAkGA1UEBhMCQ0gxGTAXBgNVBAoMEE9JU1RFIEZvdW5kYXRp +b24xITAfBgNVBAMMGE9JU1RFIENsaWVudCBSb290IEVDQyBHMTB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABIhOaB/Jnr46BFsVwzX0zFDFCK04bqg80gK6zKsl/XVA/WcZ +nxsKXfbLFnv5XB6C3BVE1Jw8bWGTRfRPz2K53z5TjZrUSt6Iqgum8dRh1h501Riy +xU1M74B77A3rgzlUlqNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSZ +Vzs5sS0AjCFmjJVpnG117Iw/+jAdBgNVHQ4EFgQUmVc7ObEtAIwhZoyVaZxtdeyM +P/owDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMQCW/+SCThYiW6CF +GDw9Oo8gBggl5/WRNhmte7TfW2YSN3Nw7c0FKAdeCM4NQl8ZkQICMGdJh64GQR0g +0zGmqiY38SeKYQ3+mgZDpy6eJkejMhiL6F5QBfGwekh23tuhYkq6dw== +-----END CERTIFICATE-----`)) + + // OISTE Client Root RSA G1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIQNBdvWQGIG6ql3chIu7Q7czANBgkqhkiG9w0BAQwFADBL +MQswCQYDVQQGEwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UE +AwwYT0lTVEUgQ2xpZW50IFJvb3QgUlNBIEcxMB4XDTIzMDUzMTE0MjMyOVoXDTQ4 +MDUyNDE0MjMyOFowSzELMAkGA1UEBhMCQ0gxGTAXBgNVBAoMEE9JU1RFIEZvdW5k +YXRpb24xITAfBgNVBAMMGE9JU1RFIENsaWVudCBSb290IFJTQSBHMTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBALpP/v5UE7WEPLzg0zHxHW7cxFNx+uQ5 +UUN2fZIfgX8Aa0HC5trcGE1sF1lwCTNi7GmILbDdWflhYGBW8ba07+uH0BP+w89v +j345WFGziQKOVJUeIl+rKAVDJ/hF9AlCJpT+vRN4u5HyEBCcDWd82mQg63owGrpI +DXhUKpkxNKvLpmrnDGc5ZqQmqCco5/PmPHPkK8xvMS4TdGHLaObSM85SvH5lJFoh +gTFDqrKc0RjnYTxSr4CJ6TRG3vlNmVptHb3GJdGTVY74J5JDOoyVRUDjiRinhsFZ +mMrbJhwTwIyBuZiwrWmtbhjje2JB9a02/gu0eyBfn6lu+ZmCElLSisRUeLR890Gb +A+cHXrPCuUlkZ5IWxGCQDrCCfTOt0Dbq0XZrfIhHmKwb+bRQjGGBadgx8436PvL1 +S6/Owx3vXygb6xjWoFhSMr5Cb81JlyLBcLnT42BP3oOCoE4wvXNTwr0X/aDAmI/q +DhcH5kOVIE7bEaj549O4J0cMJ9sS64FVzHXbn9MXQ8T764oobemvRFBaQ/vxOeKT +UM+Y/ESWWDilpe1Fw1JCBafv5TykrD3n1qlWBaqww6cZ5OU911dEbZQRH8pwyPy5 +TMxBWoN0U5B4z9bULk+xqk0u9dEIWzpk78inqHph7Oym1YhOtlTUWJHCJWSRvAoU +PZIUmrULBukvAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU +KYIlNQo6vpIr5AkD5OyPjThyOcswHQYDVR0OBBYEFCmCJTUKOr6SK+QJA+Tsj404 +cjnLMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAbSOGwv/14MjA +VYpgMcyXQ0dwQ9Pj7FL608Ke+4kyGspGk08Elyvb0JyEDZUHQlT+72kh35IDLo83 +ISN3qXc3bKDErpynWDlKFZdiRoNRIO0/wqPxw2In0KwTHv48Uh2Q1WPxqV7qf+fn +65ZaUezUqRvjDJRmrMuIkkm+c1yK4Gq8poHNs1zUI5LITfkgjHCUS2ht8o8ebDX3 +6F/U170gN1Jm/yu7SWa3cagsX3MPB5LnTl+lBtvJijyXxULqfQ+BG1frngwP/6Mn +IElTprM6TMttMDXa8vCa/lDfbVwkPU13an2GX0zQ4aa0rgQTAZDxgGiEB5SCB4Pr +keWTDnWRrqMjIElk1Lo5lldw7lU0KHzWr8qpnubJAckHwdBEsYC0UVCqj/ac5Wdz +0BvqgzUXL1DG3lbHu6MDy+KhGOj4zlEGo9IDQGEap2dXg/zRErkoqtpOa9Wc2IU3 +2r0i1zRZnBqmznjWlHgHBg+xkyGgSccQngquUXca+XGQw62YD4opamABqk+tIAMt +ao6jC2rW/ZMMimHLvSjxX3H9uDM51krx9rJoUj5lj0OdgSQk9ihMNaf9MwqleMEE +H+xJasSu1UQWpqeNf9ohlj6ouhZn1Kmh58Ka+BDZO5ruaPYvAO7Lu2aNIjiG9L9f +eKnIoB1au3VQ+VILDx0CLBQa84dqd/M= +-----END CERTIFICATE-----`)) + + // OISTE Server Root ECC G1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICNTCCAbqgAwIBAgIQI/nD1jWvjyhLH/BU6n6XnTAKBggqhkjOPQQDAzBLMQsw +CQYDVQQGEwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwY +T0lTVEUgU2VydmVyIFJvb3QgRUNDIEcxMB4XDTIzMDUzMTE0NDIyOFoXDTQ4MDUy +NDE0NDIyN1owSzELMAkGA1UEBhMCQ0gxGTAXBgNVBAoMEE9JU1RFIEZvdW5kYXRp +b24xITAfBgNVBAMMGE9JU1RFIFNlcnZlciBSb290IEVDQyBHMTB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABBcv+hK8rBjzCvRE1nZCnrPoH7d5qVi2+GXROiFPqOujvqQy +cvO2Ackr/XeFblPdreqqLiWStukhEaivtUwL85Zgmjvn6hp4LrQ95SjeHIC6XG4N +2xml4z+cKrhAS93mT6NjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQ3 +TYhlz/w9itWj8UnATgwQb0K0nDAdBgNVHQ4EFgQUN02IZc/8PYrVo/FJwE4MEG9C +tJwwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYCMQCpKjAd0MKfkFFR +QD6VVCHNFmb3U2wIFjnQEnx/Yxvf4zgAOdktUyBFCxxgZzFDJe0CMQCSia7pXGKD +YmH5LVerVrkR3SW+ak5KGoJr3M/TvEqzPNcum9v4KGm8ay3sMaE641c= +-----END CERTIFICATE-----`)) + + // OISTE Server Root RSA G1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIQVaXZZ5Qoxu0M+ifdWwFNGDANBgkqhkiG9w0BAQwFADBL +MQswCQYDVQQGEwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UE +AwwYT0lTVEUgU2VydmVyIFJvb3QgUlNBIEcxMB4XDTIzMDUzMTE0MzcxNloXDTQ4 +MDUyNDE0MzcxNVowSzELMAkGA1UEBhMCQ0gxGTAXBgNVBAoMEE9JU1RFIEZvdW5k +YXRpb24xITAfBgNVBAMMGE9JU1RFIFNlcnZlciBSb290IFJTQSBHMTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAKqu9KuCz/vlNwvn1ZatkOhLKdxVYOPM +vLO8LZK55KN68YG0nnJyQ98/qwsmtO57Gmn7KNByXEptaZnwYx4M0rH/1ow00O7b +rEi56rAUjtgHqSSY3ekJvqgiG1k50SeH3BzN+Puz6+mTeO0Pzjd8JnduodgsIUzk +ik/HEzxux9UTl7Ko2yRpg1bTacuCErudG/L4NPKYKyqOBGf244ehHa1uzjZ0Dl4z +O8vbUZeUapU8zhhabkvG/AePLhq5SvdkNCncpo1Q4Y2LS+VIG24ugBA/5J8bZT8R +tOpXaZ+0AOuFJJkk9SGdl6r7NH8CaxWQrbueWhl/pIzY+m0o/DjH40ytas7ZTpOS +jswMZ78LS5bOZmdTaMsXEY5Z96ycG7mOaES3GK/m5Q9l3JUJsJMStR8+lKXHiHUh +sd4JJCpM4rzsTGdHwimIuQq6+cF0zowYJmXa92/GjHtoXAvuY8BeS/FOzJ8vD+Ho +mnqT8eDI278n5mUpezbgMxVz8p1rhAhoKzYHKyfMeNhqhw5HdPSqoBNdZH702xSu ++zrkL8Fl47l6QGzwBrd7KJvX4V84c5Ss2XCTLdyEr0YconosP4EmQufU2MVshGYR +i3drVByjtdgQ8K4p92cIiBdcuJd5z+orKu5YM+Vt6SmqZQENghPsJQtdLEByFSnT +kCz3GkPVavBpAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU +8snBDw1jALvsRQ5KH7WxszbNDo0wHQYDVR0OBBYEFPLJwQ8NYwC77EUOSh+1sbM2 +zQ6NMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQwFAAOCAgEANGd5sjrG5T33 +I3K5Ce+SrScfoE4KsvXaFwyihdJ+klH9FWXXXGtkFu6KRcoMQzZENdl//nk6HOjG +5D1rd9QhEOP28yBOqb6J8xycqd+8MDoX0TJD0KqKchxRKEzdNsjkLWd9kYccnbz8 +qyiWXmFcuCIzGEgWUOrKL+mlSdx/PKQZvDatkuK59EvV6wit53j+F8Bdh3foZ3dP +AGav9LEDOr4SfEE15fSmG0eLy3n31r8Xbk5l8PjaV8GUgeV6Vg27Rn9vkf195hfk +gSe7BYhW3SCl95gtkRlpMV+bMPKZrXJAlszYd2abtNUOshD+FKrDgHGdPY3ofRRs +YWSGRqbXVMW215AWRqWFyp464+YTFrYVI8ypKVL9AMb2kI5Wj4kI3Zaq5tNqqYY1 +9tVFeEJKRvwDyF7YZvZFZSS0vod7VSCd9521Kvy5YhnLbDuv0204bKt7ph6N/Ome +/msVuduCmsuY33OhkKCgxeDoAaijFJzIwZqsFVAzje18KotzlUBDJvyBpCpfOZC3 +J8tRd/iWkx7P8nd9H0aTolkelUTFLXVksNb54Dxp6gS1HAviRkRNQzuXSXERvSS2 +wq1yVAb+axj5d9spLFKebXd7Yv0PTY6YMjAwcRLWJTXjn/hvnLXrahut6hDTlhZy +BiElxky8j3C7DOReIoMt0r7+hVu05L0= +-----END CERTIFICATE-----`)) + + // OISTE WISeKey Global Root GA CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIID8TCCAtmgAwIBAgIQQT1yx/RrH4FDffHSKFTfmjANBgkqhkiG9w0BAQUFADCB +ijELMAkGA1UEBhMCQ0gxEDAOBgNVBAoTB1dJU2VLZXkxGzAZBgNVBAsTEkNvcHly +aWdodCAoYykgMjAwNTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl +ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQSBDQTAeFw0w +NTEyMTExNjAzNDRaFw0zNzEyMTExNjA5NTFaMIGKMQswCQYDVQQGEwJDSDEQMA4G +A1UEChMHV0lTZUtleTEbMBkGA1UECxMSQ29weXJpZ2h0IChjKSAyMDA1MSIwIAYD +VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBX +SVNlS2V5IEdsb2JhbCBSb290IEdBIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAy0+zAJs9Nt350UlqaxBJH+zYK7LG+DKBKUOVTJoZIyEVRd7jyBxR +VVuuk+g3/ytr6dTqvirdqFEr12bDYVxgAsj1znJ7O7jyTmUIms2kahnBAbtzptf2 +w93NvKSLtZlhuAGio9RN1AU9ka34tAhxZK9w8RxrfvbDd50kc3vkDIzh2TbhmYsF +mQvtRTEJysIA2/dyoJaqlYfQjse2YXMNdmaM3Bu0Y6Kff5MTMPGhJ9vZ/yxViJGg +4E8HsChWjBgbl0SOid3gF27nKu+POQoxhILYQBRJLnpB5Kf+42TMwVlxSywhp1t9 +4B3RLoGbw9ho972WG6xwsRYUC9tguSYBBQIDAQABo1EwTzALBgNVHQ8EBAMCAYYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUswN+rja8sHnR3JQmthG+IbJphpQw +EAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBAEuh/wuHbrP5wUOx +SPMowB0uyQlB+pQAHKSkq0lPjz0e701vvbyk9vImMMkQyh2I+3QZH4VFvbBsUfk2 +ftv1TDI6QU9bR8/oCy22xBmddMVHxjtqD6wU2zz0c5ypBd8A3HR4+vg1YFkCExh8 +vPtNsCBtQ7tgMHpnM1zFmdH4LTlSc/uMqpclXHLZCB6rTjzjgTGfA6b7wP4piFXa +hNVQA7bihKOmNqoROgHhGEvWRGizPflTdISzRpFGlgC3gCy24eMQ4tui5yiPAZZi +Fj4A4xylNoEYokxSdsARo27mHbrjWr42U8U+dY+GaSlYU7Wcu2+fXMUY7N0v4ZjJ +/L7fCg0= +-----END CERTIFICATE-----`)) + + // OISTE WISeKey Global Root GB CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt +MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg +Rm91bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9i +YWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAwMzJaFw0zOTEyMDExNTEwMzFaMG0x +CzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQLExlPSVNURSBG +b3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh +bCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3 +HEokKtaXscriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGx +WuR51jIjK+FTzJlFXHtPrby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX +1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNk +u7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4oQnc/nSMbsrY9gBQHTC5P +99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvgGUpuuy9r +M2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUw +AwEB/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUB +BAMCAQAwDQYJKoZIhvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrgh +cViXfa43FK8+5/ea4n32cZiZBKpDdHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5 +gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0VQreUGdNZtGn//3ZwLWoo4rO +ZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEuiHZeeevJuQHHf +aPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic +Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= +-----END CERTIFICATE-----`)) + + // OISTE WISeKey Global Root GC CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQsw +CQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91 +bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwg +Um9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRaFw00MjA1MDkwOTU4MzNaMG0xCzAJ +BgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQLExlPSVNURSBGb3Vu +ZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2JhbCBS +b290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4ni +eUqjFqdrVCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4W +p2OQ0jnUsYd4XxiWD1AbNTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7T +rYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0EAwMDaAAwZQIwJsdpW9zV +57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtkAjEA2zQg +Mgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9 +-----END CERTIFICATE-----`)) + + // Security Communication ECC RootCA1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICODCCAb6gAwIBAgIJANZdm7N4gS7rMAoGCCqGSM49BAMDMGExCzAJBgNVBAYT +AkpQMSUwIwYDVQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMSswKQYD +VQQDEyJTZWN1cml0eSBDb21tdW5pY2F0aW9uIEVDQyBSb290Q0ExMB4XDTE2MDYx +NjA1MTUyOFoXDTM4MDExODA1MTUyOFowYTELMAkGA1UEBhMCSlAxJTAjBgNVBAoT +HFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xKzApBgNVBAMTIlNlY3VyaXR5 +IENvbW11bmljYXRpb24gRUNDIFJvb3RDQTEwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AASkpW9gAwPDvTH00xecK4R1rOX9PVdu12O/5gSJko6BnOPpR27KkBLIE+Cnnfdl +dB9sELLo5OnvbYUymUSxXv3MdhDYW72ixvnWQuRXdtyQwjWpS4g8EkdtXP9JTxpK +ULGjQjBAMB0GA1UdDgQWBBSGHOf+LaVKiwj+KBH6vqNm+GBZLzAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjAVXUI9/Lbu +9zuxNuie9sRGKEkz0FhDKmMpzE2xtHqiuQ04pV1IKv3LsnNdo4gIxwwCMQDAqy0O +be0YottT6SXbVQjgUMzfRGEWgqtJsLKB7HOHeLRMsmIbEvoWTSVLY70eN9k= +-----END CERTIFICATE-----`)) + + // Security Communication RootCA2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDEl +MCMGA1UEChMcU0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMe +U2VjdXJpdHkgQ29tbXVuaWNhdGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoX +DTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMCSlAxJTAjBgNVBAoTHFNFQ09NIFRy +dXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3VyaXR5IENvbW11bmlj +YXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAV +OVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGr +zbl+dp+++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVM +VAX3NuRFg3sUZdbcDE3R3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQ +hNBqyjoGADdH5H5XTz+L62e4iKrFvlNVspHEfbmwhRkGeC7bYRr6hfVKkaHnFtWO +ojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1KEOtOghY6rCcMU/Gt1SSw +awNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8QIH4D5cs +OPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpF +coJxDjrSzG+ntKEju/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXc +okgfGT+Ok+vx+hfuzU7jBBJV1uXk3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8 +t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6qtnRGEmyR7jTV7JqR50S+kDFy +1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29mvVXIwAHIRc/ +SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 +-----END CERTIFICATE-----`)) + + // AAA Certificate Services + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb +MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow +GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj +YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL +MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE +BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM +GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua +BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe +3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4 +YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR +rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm +ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU +oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF +MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v +QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t +b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF +AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q +GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz +Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2 +G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi +l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3 +smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg== +-----END CERTIFICATE-----`)) + + // COMODO Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCB +gTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNV +BAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjEyMDEwMDAw +MDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3Jl +YXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01P +RE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3 +UcEbVASY06m/weaKXTuH+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI +2GqGd0S7WWaXUF601CxwRM/aN5VCaTwwxHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8 +Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV4EajcNxo2f8ESIl33rXp ++2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA1KGzqSX+ +DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5O +nKVIrLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW +/zAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6g +PKA6hjhodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9u +QXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOCAQEAPpiem/Yb6dc5t3iuHXIY +SdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CPOGEIqB6BCsAv +IC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/ +RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4 +zJVSk/BwJVmcIGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5dd +BA6+C4OmF4O5MBKgxTMVBbkN+8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IB +ZQ== +-----END CERTIFICATE-----`)) + + // COMODO ECC Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTEL +MAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE +BxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMT +IkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwMzA2MDAw +MDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdy +ZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09N +T0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSR +FtSrYpn1PlILBs5BAH+X4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0J +cfRK9ChQtP6IHG4/bC8vCVlbpVsLM5niwz2J+Wos77LTBumjQjBAMB0GA1UdDgQW +BBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VGFAkK+qDm +fQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdv +GDeAU/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= +-----END CERTIFICATE-----`)) + + // COMODO RSA Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCB +hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV +BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMTE5 +MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgT +EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR +Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR +6FSS0gpWsawNJN3Fz0RndJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8X +pz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZFGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC +9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+5eNu/Nio5JIk2kNrYrhV +/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pGx8cgoLEf +Zd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z ++pUX2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7w +qP/0uK3pN/u6uPQLOvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZah +SL0896+1DSJMwBGB7FY79tOi4lu3sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVIC +u9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+CGCe01a60y1Dma/RMhnEw6abf +Fobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5WdYgGq/yapiq +crxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E +FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB +/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvl +wFTPoCWOAvn9sKIN9SCYPBMtrFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM +4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+nq6PK7o9mfjYcwlYRm6mnPTXJ9OV +2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSgtZx8jb8uk2Intzna +FxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwWsRqZ +CuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiK +boHGhfKppC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmcke +jkk9u+UJueBPSZI9FoJAzMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yL +S0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHqZJx64SIDqZxubw5lT2yHh17zbqD5daWb +QOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk527RH89elWsn2/x20Kk4yl +0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7ILaZRfyHB +NVOFBkpdn627G190 +-----END CERTIFICATE-----`)) + + // Entrust Root Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMC +VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0 +Lm5ldC9DUFMgaXMgaW5jb3Jwb3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMW +KGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsGA1UEAxMkRW50cnVzdCBSb290IENl +cnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0MloXDTI2MTEyNzIw +NTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMTkw +NwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSBy +ZWZlcmVuY2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNV +BAMTJEVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBALaVtkNC+sZtKm9I35RMOVcF7sN5EUFo +Nu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYszA9u3g3s+IIRe7bJWKKf4 +4LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOwwCj0Yzfv9 +KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGI +rb68j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi +94DkZfs0Nw4pgHBNrziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOB +sDCBrTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAi +gA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1MzQyWjAfBgNVHSMEGDAWgBRo +kORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DHhmak8fdLQ/uE +vW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA +A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9t +O1KzKtvn1ISMY/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6Zua +AGAT/3B+XxFNSRuzFVJ7yVTav52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP +9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTSW3iDVuycNsMm4hH2Z0kdkquM++v/ +eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0tHuu2guQOHXvgR1m +0vdXcDazv/wor3ElhVsT/h5/WrQ8 +-----END CERTIFICATE-----`)) + + // Entrust Root Certification Authority - EC1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkG +A1UEBhMCVVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3 +d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVu +dHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEzMDEGA1UEAxMq +RW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRUMxMB4XDTEy +MTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYwFAYD +VQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0 +L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0g +Zm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBD +ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEVDMTB2MBAGByqGSM49AgEGBSuBBAAi +A2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHyAsWfoPZb1YsGGYZPUxBt +ByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef9eNi1KlH +Bz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVC +R98crlOZF7ZvHH3hvxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nX +hTcGtXsI/esni0qU+eH6p44mCOh8kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G +-----END CERTIFICATE-----`)) + + // Entrust Root Certification Authority - G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMC +VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50 +cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3Qs +IEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVz +dCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwHhcNMDkwNzA3MTcy +NTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVu +dHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwt +dGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0 +aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmlj +YXRpb24gQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP/vaCeb9zYQYKpSfYs1/T +RU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXzHHfV1IWN +cCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hW +wcKUs/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1 +U1+cPvQXLOZprE4yTGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0 +jaWvYkxN4FisZDQSA/i2jZRjJKRxAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ60B7vfec7aVHUbI2fkBJmqzAN +BgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5ZiXMRrEPR9RP/ +jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ +Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v +1fN2D807iDginWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4R +nAuknZoh8/CbCzB428Hch0P+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmH +VHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xOe4pIb4tF9g== +-----END CERTIFICATE-----`)) + + // Entrust Root Certification Authority - G4 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIGSzCCBDOgAwIBAgIRANm1Q3+vqTkPAAAAAFVlrVgwDQYJKoZIhvcNAQELBQAw +gb4xCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQL +Ex9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykg +MjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMjAw +BgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEc0 +MB4XDTE1MDUyNzExMTExNloXDTM3MTIyNzExNDExNlowgb4xCzAJBgNVBAYTAlVT +MRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1 +c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJ +bmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3Qg +Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEc0MIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAsewsQu7i0TD/pZJH4i3DumSXbcr3DbVZwbPLqGgZ +2K+EbTBwXX7zLtJTmeH+H17ZSK9dE43b/2MzTdMAArzE+NEGCJR5WIoV3imz/f3E +T+iq4qA7ec2/a0My3dl0ELn39GjUu9CH1apLiipvKgS1sqbHoHrmSKvS0VnM1n4j +5pds8ELl3FFLFUHtSUrJ3hCX1nbB76W1NhSXNdh4IjVS70O92yfbYVaCNNzLiGAM +C1rlLAHGVK/XqsEQe9IFWrhAnoanw5CGAlZSCXqc0ieCU0plUmr1POeo8pyvi73T +DtTUXm6Hnmo9RR3RXRv06QqsYJn7ibT/mCzPfB3pAqoEmh643IhuJbNsZvc8kPNX +wbMv9W3y+8qh+CmdRouzavbmZwe+LGcKKh9asj5XxNMhIWNlUpEbsZmOeX7m640A +2Vqq6nPopIICR5b+W45UYaPrL0swsIsjdXJ8ITzI9vF01Bx7owVV7rtNOzK+mndm +nqxpkCIHH2E6lr7lmk/MBTwoWdPBDFSoWWG9yHJM6Nyfh3+9nEg2XpWjDrk4JFX8 +dWbrAuMINClKxuMrLzOg2qOGpRKX/YAr2hRC45K9PvJdXmd0LhyIRyk0X+IyqJwl +N4y6mACXi0mWHv0liqzc2thddG5msP9E36EYxr5ILzeUePiVSj9/E15dWf10hkNj +c0kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD +VR0OBBYEFJ84xFYjwznooHFs6FRM5Og6sb9nMA0GCSqGSIb3DQEBCwUAA4ICAQAS +5UKme4sPDORGpbZgQIeMJX6tuGguW8ZAdjwD+MlZ9POrYs4QjbRaZIxowLByQzTS +Gwv2LFPSypBLhmb8qoMi9IsabyZIrHZ3CL/FmFz0Jomee8O5ZDIBf9PD3Vht7LGr +hFV0d4QEJ1JrhkzO3bll/9bGXp+aEJlLdWr+aumXIOTkdnrG0CSqkM0gkLpHZPt/ +B7NTeLUKYvJzQ85BK4FqLoUWlFPUa19yIqtRLULVAJyZv967lDtX/Zr1hstWO1uI +AeV8KEsD+UmDfLJ/fOPtjqF/YFOOVZ1QNBIPt5d7bIdKROf1beyAN/BYGW5KaHbw +H5Lk6rWS02FREAutp9lfx1/cH6NcjKF+m7ee01ZvZl4HliDtC3T7Zk6LERXpgUl+ +b7DUUH8i119lAg2m9IUe2K4GS0qn0jFmwvjO5QimpAKWRGhXxNUzzxkvFMSUHHuk +2fCfDrGA4tGeEWSpiBE6doLlYsKA2KSD7ZPvfC+QsDJMlhVoSFLUmQjAJOgc47Ol +IQ6SwJAfzyBfyjs4x7dtOvPmRLgOMWuIjnDrnBdSqEGULoe256YSxXXfW8AKbnuk +5F6G+TaU33fD6Q3AOfF5u0aOq0NZJ7cguyPpVkAh7DE9ZapD8j3fcEThuk0mEDuY +n/PIjhs4ViFqUZPTkcpG2om3PVODLAgfi49T3f+sHw== +-----END CERTIFICATE-----`)) + + // Entrust.net Certification Authority (2048) + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChML +RW50cnVzdC5uZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBp +bmNvcnAuIGJ5IHJlZi4gKGxpbWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5 +IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNVBAMTKkVudHJ1c3QubmV0IENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQxNzUwNTFaFw0yOTA3 +MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3d3d3 +LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxp +YWIuKTElMCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEG +A1UEAxMqRW50cnVzdC5uZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgp +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArU1LqRKGsuqjIAcVFmQq +K0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOLGp18EzoOH1u3Hs/lJBQe +sYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSrhRSGlVuX +MlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVT +XTzWnLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/ +HoZdenoVve8AjhUiVBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH +4QIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJKoZIhvcNAQEFBQADggEBADub +j1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPyT/4xmf3IDExo +U8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf +zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5b +u/8j72gZyxKTJ1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+ +bYQLCIt+jerXmCHG8+c8eS9enNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/Er +fF6adulZkMV8gzURZVE= +-----END CERTIFICATE-----`)) + + // Sectigo Public Email Protection Root E46 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICMTCCAbegAwIBAgIQbvXTp0GOoFlApzBr0kBlVjAKBggqhkjOPQQDAzBaMQsw +CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTEwLwYDVQQDEyhT +ZWN0aWdvIFB1YmxpYyBFbWFpbCBQcm90ZWN0aW9uIFJvb3QgRTQ2MB4XDTIxMDMy +MjAwMDAwMFoXDTQ2MDMyMTIzNTk1OVowWjELMAkGA1UEBhMCR0IxGDAWBgNVBAoT +D1NlY3RpZ28gTGltaXRlZDExMC8GA1UEAxMoU2VjdGlnbyBQdWJsaWMgRW1haWwg +UHJvdGVjdGlvbiBSb290IEU0NjB2MBAGByqGSM49AgEGBSuBBAAiA2IABLinUpT1 +PgWwG/YfsdN+ueQFZlSAzmylaH3kU1LbgvrEht9DePfIrRa8P3gyy2vTSdZE5bN+ +n3umxizy4rbTibCaPEvOiUvGxss6SWAPRrxtTnqcyZuFewq2sEfCiOPU0aNCMEAw +HQYDVR0OBBYEFC1OjKfCI7JXqQZrPmsrifPDXkfOMA4GA1UdDwEB/wQEAwIBhjAP +BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gAMGUCMQCSnRpZY0VYjhsW5H16 +bDZIMB8rcueQMzT9JKLGBoxvOzJXWvj+xkkSU5rZELKZUXICMAUlKjMh/JPmIqLM +cFUoNVaiB8QhhCMaTEyZUJmSFMtK3Fb79dOPaiz1cTr4izsDng== +-----END CERTIFICATE-----`)) + + // Sectigo Public Email Protection Root R46 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgDCCA2igAwIBAgIQHUSeuQ2DkXSu3fLriLemozANBgkqhkiG9w0BAQwFADBa +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTEwLwYDVQQD +EyhTZWN0aWdvIFB1YmxpYyBFbWFpbCBQcm90ZWN0aW9uIFJvb3QgUjQ2MB4XDTIx +MDMyMjAwMDAwMFoXDTQ2MDMyMTIzNTk1OVowWjELMAkGA1UEBhMCR0IxGDAWBgNV +BAoTD1NlY3RpZ28gTGltaXRlZDExMC8GA1UEAxMoU2VjdGlnbyBQdWJsaWMgRW1h +aWwgUHJvdGVjdGlvbiBSb290IFI0NjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +AgoCggIBAJHlG/qqbTcrdccuXxSl2yyXtixGj2nZ7JYt8x1avtMdI+ZoCf9KEXMa +rmefdprS5+y42V8r+SZWUa92nan8F+8yCtAjPLosT0eD7J0FaEJeBuDV6CtoSJey ++vOkcTV9NJsXi39NDdvcTwVMlGK/NfovyKccZtlxX+XmWlXKq/S4dxlFUEVOSqvb +nmbBGbc3QshWpUAS+TPoOEU6xoSjAo4vJLDDQYUHSZzP3NHyJm/tMxwzZypFN9mF +ZSIasbUQUglrA8YfcD2RxH2QPe1m+JD/JeDtkqKLMSmtnBJmeGOdV+z7C96O3IvL +Oql39Lrl7DiMi+YTZqdpWMOCGhrN8Z/YU5JOSX2pRefxQyFatz5AzWOJz9m/x1AL +4bzniJatntQX2l3P4JH9phDUuQOBm2ms+4SogTXrG+tobHxgPsPfybSudB1Ird1u +EYbhKmo2Fq7IzrzbWPxAk0DYjlOXwqwiOOWIMbMuoe/s4EIN6v+TVkoGpJtMAmhk +j1ZQwYEF/cvbxdcV8mu1dsOj+TLOyrVKqRt9Gdx/x2p+ley2uI39lUqcoytti/Fw +5UcrAFzkuZ7U+NlYKdDL4ChibK6cYuLMvDaTQfXv/kZilbBXSnQsR1Ipnd2ioU9C +wpLOLVBSXowKoffYncX4/TaHTlf9aKFfmYMc8LXd6JLTZUBVypaFAgMBAAGjQjBA +MB0GA1UdDgQWBBSn15V360rDJ82TvjdMJoQhFH1dmDAOBgNVHQ8BAf8EBAMCAYYw +DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQwFAAOCAgEANNLxFfOTAdRyi/Cr +CB8TPHO0sKvoeNlsupqvJuwQgOUNUzHd4/qMUSIkMze4GH46+ljoNOWM4KEfCUHS +Nz/Mywk1Qojp/BHXz0KqpHC2ccFTvcV0r8QiJGPPYoJ9yctRwYiQbVtcvvuZqLq2 +hrDpZgvlG2uv6iuGp9+oI0yWP09XQhgVg0Pxhia3KgPOC53opWgejG+9heMbUY/n +Fy8r0NZ4wi3dcojUZZ76mdR+55cKkgGapamEOgwqdD0zGMiH9+ik9YZCOf1rdSn8 +AAasoqUaVI7pUEkXZq9LBC2blIClVKuMVxdEnw/WaGRytEseAcfZm5TZg5mvEgUR +o5gi0vJXyiT5ujgVEki6Yzv8i5V41nIHVszN/J0c0MVkO2M0zwSZircweXq28sbV +2VR6hwt+TveE7BTziBYS8dWuChoJ7oat5av9rsMpeXTDAV8Rm991mcZK95uPbEns +IS+0AlmzLdBykLoLFHR4S8/BX1VyjlQrE876WAzTuyzZqZFh+PjxtnvevKnMkgTM +S2tfc4C2Ie1QT9d2h27O39K3vWKhfVhiaEVStj/eEtvtBGmedoiqAW3ahsdgG8NS +rDfsUHGAciohRQpTRzwZ643SWQTeJbDrHzVvYH3Xtca7CyeN4E1U5c8dJgFuOzXI +IBKJg/DS7Vg7NJ27MfUy/THzVho= +-----END CERTIFICATE-----`)) + + // Sectigo Public Server Authentication Root E46 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQsw +CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T +ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcN +MjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYG +A1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccC +WvkEN/U0NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+ +6xnOQ6OjQjBAMB0GA1UdDgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8B +Af8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNnADBkAjAn7qRa +qCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RHlAFWovgzJQxC36oCMB3q +4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21USAGKcw== +-----END CERTIFICATE-----`)) + + // Sectigo Public Server Authentication Root R46 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa +ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz +SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf +iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X +ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 +IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS +VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE +SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu ++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt +8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L +HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt +zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P +AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ +YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 +gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA +Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB +JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX +DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui +TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 +dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 +LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp +0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY +QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE-----`)) + + // USERTrust ECC Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL +MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl +eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT +JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx +MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT +Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg +VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm +aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo +I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng +o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G +A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB +zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW +RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= +-----END CERTIFICATE-----`)) + + // USERTrust RSA Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB +iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl +cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV +BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw +MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV +BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B +3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY +tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/ +Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2 +VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT +79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6 +c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT +Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l +c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee +UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE +Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G +A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF +Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO +VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3 +ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs +8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR +iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze +Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ +XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/ +qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB +VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB +L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG +jjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE-----`)) + + // SSL.com Client ECC Root CA 2022 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICQDCCAcagAwIBAgIQdvhIHq7wPHAf4D8lVAGD1TAKBggqhkjOPQQDAzBRMQsw +CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSgwJgYDVQQDDB9T +U0wuY29tIENsaWVudCBFQ0MgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzAzMloX +DTQ2MDgxOTE2MzAzMVowUTELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jw +b3JhdGlvbjEoMCYGA1UEAwwfU1NMLmNvbSBDbGllbnQgRUNDIFJvb3QgQ0EgMjAy +MjB2MBAGByqGSM49AgEGBSuBBAAiA2IABC1Tfp+LPrM2ulDizOvcuiaK04wGP2cP +7/UX5dSumkYqQQEHaedncfHCAzbG8CtSjs8UkmikPnBREmmNeKKCyikUwOSUIrJE +kmBvyASkZ9Wi0PPQ1+qOPA+60kBHkDTufaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAf +BgNVHSMEGDAWgBS3/i1ixYFTzVIaL11goMNd+7IcHDAdBgNVHQ4EFgQUt/4tYsWB +U81SGi9dYKDDXfuyHBwwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUC +ME0HES0R+7kmwyHdcuEX/MHPFOpJznGHjtZT3BHNXVSKr9kt9IxR6rxmR+J/lYNg +ZQIxAIwhTE+75bBQ35BiSebMkdv4P11xkQiOT5LJf6Zc6hN+7W3E6MMqb1wR4aXz +alqaTQ== +-----END CERTIFICATE-----`)) + + // SSL.com Client RSA Root CA 2022 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFjzCCA3egAwIBAgIQdq/uiJMVRbZQU5uAnKTfmjANBgkqhkiG9w0BAQsFADBR +MQswCQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSgwJgYDVQQD +DB9TU0wuY29tIENsaWVudCBSU0EgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzEw +N1oXDTQ2MDgxOTE2MzEwNlowUTELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBD +b3Jwb3JhdGlvbjEoMCYGA1UEAwwfU1NMLmNvbSBDbGllbnQgUlNBIFJvb3QgQ0Eg +MjAyMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALhY20Yw+8k/48jw +ATM04tpIqBjpIG6a1wHh1SmPMLQjauTLYrC+4p8gvT5UoDlox4Y3ZnQGBu90K9rc +n4SpUi+Q0u5+fPulIq1vcEZnlj0p1KO7VnsUBFnBIWNEHrIfElyQh2UNiPYeiCLi +Y1S78zb41n/c2v8pNanGbg5pWz/YvoKHFXBdsMdcEg9jpjjNz3O5ww6JJjcbP2Ic +MmnRm9n/VZAx3rFj3c/FdHf874ghU78AMRomLAAwpV9s4+T2AIrKmIecdAN6i2bs +fv2jjzUlXHils6T7PW2pivBsiIKL/UrQb+TXo7SONEk4vs5F5dIcyl7CNxSLzWZW +Mzed5WvsQ5JkoELadW/AFez5ab00uYp7+hb7Vf5SIOgEBFZWZfU3RJjIikbpt6y4 +6L5ijlQ2W/c7cL9d7i26X95CGYbwf4vrCMvYvuoOQkKgNnNXF+0y6tCN6Acbm5no +xJpiBA5I9zwSuvdYwZqM6cewIzZWNB3LbNq6B4Qd/dGsn+bCie/DuWwYs2mHV1+1 +DDhbpyEkKjunNJGetFTqKE/TwaOL5OYr1fKdv5thACLd1ktEHz9dVv7enHjMmVuq +5L2620NLrUwmTKNNNIpsdDYT22L8m7IFgf+uPwzN9hui9DnnyvVMXPtUdzWAWsAS +oRMBM2c9nYGhqfWFJFiIeOf042hVAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8w +HwYDVR0jBBgwFoAU8DhClDSpPAB/Uu45pfdLDbxqfSMwHQYDVR0OBBYEFPA4QpQ0 +qTwAf1LuOaX3Sw28an0jMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AgEAmU/b8OrWEfoq/cirbeQOc2LSQp8V/nxwUj9kh4IxP0VALuEinwZmKfyW0y2N +tjjH2fMnwVkpoIz2cyQPKCLXTmHdE93bnzJSk/tPzOo4PJhqA6sWryHRQq59RSvq +xM+KWZ+CcHY6+GImyRCXWEAkpC25LymAJ+GJa3LKSQhxN1MF8YDO00IC0vzC0ZQG +7gfi9oPif5/nu1bDW7/dlZMJHiTBzybNraSuwrRp56q17TeU6d3RY4VrmnpKVnbc +GYUo1OTGpNi4lkF30LRZ8UYFh4cCH2m5ghjQQ9km2hpnqNZ1durybQ5C/4gmom6E +/n5iG/DGPe3AHGrHkda4ADdJm7mEBaHNbjHWROpTi7pTmB2hkIrphfgb8pNYw8jc +miZPPiDPT0PzEIx/EGF6NsqqC33Mn0dEWa6llcaZU+MHaz1JELAY/10OhUMUS+dr +00q1smBh3GlJAiNd6JJxw5yfRWd5HtwyhrqqVTxkbzK1EEAV3nJAeOBucLtu6wno +OdmsupJ13UPKugGVrRqBKzrw48UvDBhNEMauwO3+BVJ/GQXLqa81CAw4IuT+VuVT +Pr/k1rPZCMM91TMygSTFqeFlEbgyMzBxGEkdGkXGmhSKWDkobvPLUblJJmR4A8eR +EYOpuZA0tm+qBZ6FKFeZvn8nBkliTaH8CeErRglMFJtWj0U= +-----END CERTIFICATE-----`)) + + // SSL.com EV Root Certification Authority ECC + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMC +VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T +U0wgQ29ycG9yYXRpb24xNDAyBgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNTIzWhcNNDEwMjEyMTgx +NTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv +dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NMLmNv +bSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMA +VIbc/R/fALhBYlzccBYy3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1Kthku +WnBaBu2+8KGwytAJKaNjMGEwHQYDVR0OBBYEFFvKXuXe0oGqzagtZFG22XKbl+ZP +MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe5d7SgarNqC1kUbbZcpuX +5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJN+vp1RPZ +ytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZg +h5Mmm7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg== +-----END CERTIFICATE-----`)) + + // SSL.com EV Root Certification Authority RSA R2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNV +BAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UE +CgwPU1NMIENvcnBvcmF0aW9uMTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMB4XDTE3MDUzMTE4MTQzN1oXDTQy +MDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4G +A1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYDVQQD +DC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvq +M0fNTPl9fb69LT3w23jhhqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssuf +OePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7wcXHswxzpY6IXFJ3vG2fThVUCAtZJycxa +4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTOZw+oz12WGQvE43LrrdF9 +HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+B6KjBSYR +aZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcA +b9ZhCBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQ +Gp8hLH94t2S42Oim9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQV +PWKchjgGAGYS5Fl2WlPAApiiECtoRHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMO +pgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+SlmJuwgUHfbSguPvuUCYHBBXtSu +UDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48+qvWBkofZ6aY +MBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV +HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa4 +9QaAJadz20ZpqJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBW +s47LCp1Jjr+kxJG7ZhcFUZh1++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5 +Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nxY/hoLVUE0fKNsKTPvDxeH3jnpaAg +cLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2GguDKBAdRUNf/ktUM +79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDzOFSz +/bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXt +ll9ldDz7CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEm +Kf7GUmG6sXP/wwyc5WxqlD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKK +QbNmC1r7fSOl8hqw/96bg5Qu0T/fkreRrwU7ZcegbLHNYhLDkBvjJc40vG93drEQ +w/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1hlMYegouCRw2n5H9gooi +S9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX9hwJ1C07 +mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w== +-----END CERTIFICATE-----`)) + + // SSL.com Root Certification Authority ECC + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMC +VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T +U0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNDAzWhcNNDEwMjEyMTgxNDAz +WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0 +b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBS +b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI +7Z4INcgn64mMU1jrYor+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPg +CemB+vNH06NjMGEwHQYDVR0OBBYEFILRhXMw5zUE044CkvvlpNHEIejNMA8GA1Ud +EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTTjgKS++Wk0cQh6M0wDgYD +VR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCWe+0F+S8T +kdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+ +gA0z5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl +-----END CERTIFICATE-----`)) + + // SSL.com Root Certification Authority RSA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UE +BhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQK +DA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTcz +OTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv +dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv +bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2R +xFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aX +qhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcC +C52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+lSgQ3 +6YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh +/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrF +YD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93E +JNyAKoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVc +US4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8 +ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm ++Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqi +M+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4G +A1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGV +cpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBc +Hadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgs +PgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/ +q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0 +cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jr +a6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90I +H37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/Y +K9f1JmzJBjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtu +nLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406ywKBjYZC6VWg3dGq2ktuf +oYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShY +Ic2wBlX7Jz9TkHCpBB5XJ7k= +-----END CERTIFICATE-----`)) + + // SSL.com TLS ECC Root CA 2022 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICOjCCAcCgAwIBAgIQFAP1q/s3ixdAW+JDsqXRxDAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxT +U0wuY29tIFRMUyBFQ0MgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzM0OFoXDTQ2 +MDgxOTE2MzM0N1owTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh +dGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgRUNDIFJvb3QgQ0EgMjAyMjB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABEUpNXP6wrgjzhR9qLFNoFs27iosU8NgCTWyJGYm +acCzldZdkkAZDsalE3D07xJRKF3nzL35PIXBz5SQySvOkkJYWWf9lCcQZIxPBLFN +SeR7T5v15wj4A4j3p8OSSxlUgaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME +GDAWgBSJjy+j6CugFFR781a4Jl9nOAuc0DAdBgNVHQ4EFgQUiY8vo+groBRUe/NW +uCZfZzgLnNAwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMFXjIlbp +15IkWE8elDIPDAI2wv2sdDJO4fscgIijzPvX6yv/N33w7deedWo1dlJF4AIxAMeN +b0Igj762TVntd00pxCAgRWSGOlDGxK0tk/UYfXLtqc/ErFc2KAhl3zx5Zn6g6g== +-----END CERTIFICATE-----`)) + + // SSL.com TLS RSA Root CA 2022 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIQb77arXO9CEDii02+1PdbkTANBgkqhkiG9w0BAQsFADBO +MQswCQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQD +DBxTU0wuY29tIFRMUyBSU0EgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzQyMloX +DTQ2MDgxOTE2MzQyMVowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jw +b3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgUlNBIFJvb3QgQ0EgMjAyMjCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANCkCXJPQIgSYT41I57u9nTP +L3tYPc48DRAokC+X94xI2KDYJbFMsBFMF3NQ0CJKY7uB0ylu1bUJPiYYf7ISf5OY +t6/wNr/y7hienDtSxUcZXXTzZGbVXcdotL8bHAajvI9AI7YexoS9UcQbOcGV0ins +S657Lb85/bRi3pZ7QcacoOAGcvvwB5cJOYF0r/c0WRFXCsJbwST0MXMwgsadugL3 +PnxEX4MN8/HdIGkWCVDi1FW24IBydm5MR7d1VVm0U3TZlMZBrViKMWYPHqIbKUBO +L9975hYsLfy/7PO0+r4Y9ptJ1O4Fbtk085zx7AGL0SDGD6C1vBdOSHtRwvzpXGk3 +R2azaPgVKPC506QVzFpPulJwoxJF3ca6TvvC0PeoUidtbnm1jPx7jMEWTO6Af77w +dr5BUxIzrlo4QqvXDz5BjXYHMtWrifZOZ9mxQnUjbvPNQrL8VfVThxc7wDNY8VLS ++YCk8OjwO4s4zKTGkH8PnP2L0aPP2oOnaclQNtVcBdIKQXTbYxE3waWglksejBYS +d66UNHsef8JmAOSqg+qKkK3ONkRN0VHpvB/zagX9wHQfJRlAUW7qglFA35u5CCoG +AtUjHBPW6dvbxrB6y3snm/vg1UYk7RBLY0ulBY+6uB0rpvqR4pJSvezrZ5dtmi2f +gTIFZzL7SAg/2SW4BCUvAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0j +BBgwFoAU+y437uOEeicuzRk1sTN8/9REQrkwHQYDVR0OBBYEFPsuN+7jhHonLs0Z +NbEzfP/UREK5MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAjYlt +hEUY8U+zoO9opMAdrDC8Z2awms22qyIZZtM7QbUQnRC6cm4pJCAcAZli05bg4vsM +QtfhWsSWTVTNj8pDU/0quOr4ZcoBwq1gaAafORpR2eCNJvkLTqVTJXojpBzOCBvf +R4iyrT7gJ4eLSYwfqUdYe5byiB0YrrPRpgqU+tvT5TgKa3kSM/tKWTcWQA673vWJ +DPFs0/dRa1419dvAJuoSc06pkZCmF8NsLzjUo3KUQyxi4U5cMj29TH0ZR6LDSeeW +P4+a0zvkEdiLA9z2tmBVGKaBUfPhqBVq6+AL8BQx1rmMRTqoENjwuSfr98t67wVy +lrXEj5ZzxOhWc5y8aVFjvO9nHEMaX3cZHxj4HCUp+UmZKbaSPaKDN7EgkaibMOlq +bLQjk2UEqxHzDh1TJElTHaE/nUiSEeJ9DU/1172iWD54nR4fK/4huxoTtrEoZP2w +AgDHbICivRZQIA9ygV/MlP+7mea6kMvq+cYMwq7FGc4zoWtcu358NFcXrfA/rs3q +r5nsLFR+jM4uElZI7xc7P0peYNLcdDa8pUNjyw9bowJWCZ4kLOGGgYz+qxcs+sji +Mho6/4UIyYOf8kpIEFR3N+2ivEC+5BB09+Rbu7nzifmPQdjH5FCQNYA+HLhNkNPU +98OwoX6EyneSMSy4kLGCenROmxMmtNVQZlR4rmA= +-----END CERTIFICATE-----`)) + + // SwissSign Gold CA - G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2ln +biBHb2xkIENBIC0gRzIwHhcNMDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBF +MQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dpc3NTaWduIEFHMR8wHQYDVQQDExZT +d2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUqt2/8 +76LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+ +bbqBHH5CjCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c +6bM8K8vzARO/Ws/BtQpgvd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqE +emA8atufK+ze3gE/bk3lUIbLtK/tREDFylqM2tIrfKjuvqblCqoOpd8FUrdVxyJd +MmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvRAiTysybUa9oEVeXBCsdt +MDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuendjIj3o02y +MszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69y +FGkOpeUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPi +aG59je883WX0XaxR7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxM +gI93e2CaHt+28kgeDrpOVG2Y4OGiGqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCB +qTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUWyV7 +lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64OfPAeGZe6Drn +8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov +L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe6 +45R88a7A3hfm5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczO +UYrHUDFu4Up+GC9pWbY9ZIEr44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5 +O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOfMke6UiI0HTJ6CVanfCU2qT1L2sCC +bwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6mGu6uLftIdxf+u+yv +GPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxpmo/a +77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCC +hdiDyyJkvC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid3 +92qgQmwLOM7XdVAyksLfKzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEpp +Ld6leNcG2mqeSz53OiATIgHQv2ieY2BrNU0LbbqhPcCT4H8js1WtciVORvnSFu+w +ZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6LqjviOvrv1vA+ACOzB2+htt +Qc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ +-----END CERTIFICATE-----`)) + + // SwissSign RSA SMIME Root CA 2022 - 1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFlzCCA3+gAwIBAgIURg7UAXGQoBqDLEpCECgV0mEbrTIwDQYJKoZIhvcNAQEL +BQAwUzELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEtMCsGA1UE +AxMkU3dpc3NTaWduIFJTQSBTTUlNRSBSb290IENBIDIwMjIgLSAxMB4XDTIyMDYw +ODEwNTMxM1oXDTQ3MDYwODEwNTMxM1owUzELMAkGA1UEBhMCQ0gxFTATBgNVBAoT +DFN3aXNzU2lnbiBBRzEtMCsGA1UEAxMkU3dpc3NTaWduIFJTQSBTTUlNRSBSb290 +IENBIDIwMjIgLSAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1Pv6 +P4aimXAJOsnWoU4Bzka1LSRIDUXprMka1zKApObTytbyKTfsmizWgc7mG52xD0Hf +WNNfqqB5WQuMrfnF+Rz7w+k1QHTDwQzLZ/ucXgwj+dAv+kyCRRy19R/4GW7ak7dO +aIN+Yi0djJUfcNnOWowhXai+CKlWbdn3uZCZxzvXvZ4uyWdXLiHO8DKD+wQB+beC +RA2yy3oJoUg+T8ALahsb7M8dnn8GkKwoBQuo5lQ7oqcsOROZqPs06/XwvQHYiBHI +rroZAkkC3IostL1hYOydeFxqiy8Xhl7yT5MAa13FsqmlGOrmbX5XBfsH/Lx8oUOx +ZhyoZ/urN/aqqrh6Qfc51YyfrnI2J+RixkOZ8aFB6f+Jnw9Jr8kUBhcnZDkNpbQq +W+w8+5/FX8Y7XSYZ8oQpuJVECVL9bDDQYo8opYGWK5QvJnXkCYwK3zjzfl04joKa +jNyers4SQjoi8jWNT9IayEkzC/o2P/8sa2ogcUzNrRA/aTKEjlzuU4hE4t3MAzCS +hnmQKkt1+1JixPRvTffbI6EY3UVTF5pjJEiJIs1+mwEcgCgDj1sr+h/jfBm95o+x +QHag8sc3sjKUEDLNpxOX8TssejQie3Q6QOKvgvjBwXj8X+Q1f8D0TPBMsuqHA3Il +WYMqCKRR3s/uqOfoQD+I8DarCU7YoKh/8+EJ27kCAwEAAaNjMGEwDwYDVR0TAQH/ +BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHwYDVR0jBBgwFoAUzC6tiYyD40CjJWml +6pJ90jc6x8YwHQYDVR0OBBYEFMwurYmMg+NAoyVppeqSfdI3OsfGMA0GCSqGSIb3 +DQEBCwUAA4ICAQAAB2YWpe3Hub+8yJGtWO1eEgWz9kabe+SEEC8HsVpeMm5tAPBe +x5piOYdN5Dzzvva6alNshG0H1GHKZ2a+mz5FMJ1R0tdaQq6dkg4jq9AVlD6omsqb +7cHCXyGjmYD8uaZhDlCAgCfH6H2g1JR6mAPn7kKL81JQXO++sHZaHAmhv4PAHnZl +0CVBW2mRk3f5jEvwLNubBgAXg/palLSGie+8CgsS+AZN0nPikThduWpLT6ev2iYl +kiMafB8nDZGE7xdy9kbrazs3qdTVmmO6XnmMKrWbojS1zJYn+XkIPH9t4P983MUm +r8OhemkW3Yc1c8ZrMWtWAS1PmdnuyuHQg962hecW+NGuM0j7Gs9dX4qEYXQHbxmw +USGyoQSxe1OP76JFrR+Y3flqBGyqNsWvjOopSUrn/1ezxjwRSRgX5maF4egj8osO +PJPEP3ZOfmKiKcsWMN4saa+Rp+JX5TNMv9iOB6J/oTVGaUqoICn/694glVmxrk0w +a9iatAMfwjjkINUO1howTGicjODtoQ+OQl3rgCoSeaYXF7SVKo40kae90ayoGkMh +i97v4KxGJWUKxiuhmz4i6Bg4tSb2LMoIIN4w0a1U/dxIFZ/Np0HXNziFME8SiEM0 +g9cqTdQAV1zlyvDd4ZIoKxh1vUekQhPpVlqNSl7ODnU1gHMZDywpi7uVuA== +-----END CERTIFICATE-----`)) + + // SwissSign RSA TLS Root CA 2022 - 1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFkzCCA3ugAwIBAgIUQ/oMX04bgBhE79G0TzUfRPSA7cswDQYJKoZIhvcNAQEL +BQAwUTELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzErMCkGA1UE +AxMiU3dpc3NTaWduIFJTQSBUTFMgUm9vdCBDQSAyMDIyIC0gMTAeFw0yMjA2MDgx +MTA4MjJaFw00NzA2MDgxMTA4MjJaMFExCzAJBgNVBAYTAkNIMRUwEwYDVQQKEwxT +d2lzc1NpZ24gQUcxKzApBgNVBAMTIlN3aXNzU2lnbiBSU0EgVExTIFJvb3QgQ0Eg +MjAyMiAtIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDLKmjiC8NX +vDVjvHClO/OMPE5Xlm7DTjak9gLKHqquuN6orx122ro10JFwB9+zBvKK8i5VUXu7 +LCTLf5ImgKO0lPaCoaTo+nUdWfMHamFk4saMla+ju45vVs9xzF6BYQ1t8qsCLqSX +5XH8irCRIFucdFJtrhUnWXjyCcplDn/L9Ovn3KlMd/YrFgSVrpxxpT8q2kFC5zyE +EPThPYxr4iuRR1VPuFa+Rd4iUU1OKNlfGUEGjw5NBuBwQCMBauTLE5tzrE0USJIt +/m2n+IdreXXhvhCxqohAWVTXz8TQm0SzOGlkjIHRI36qOTw7D59Ke4LKa2/KIj4x +0LDQKhySio/YGZxH5D4MucLNvkEM+KRHBdvBFzA4OmnczcNpI/2aDwLOEGrOyvi5 +KaM2iYauC8BPY7kGWUleDsFpswrzd34unYyzJ5jSmY0lpx+Gs6ZUcDj8fV3oT4MM +0ZPlEuRU2j7yrTrePjxF8CgPBrnh25d7mUWe3f6VWQQvdT/TromZhqwUtKiE+shd +OxtYk8EXlFXIC+OCeYSf8wCENO7cMdWP8vpPlkwGqnj73mSiI80fPsWMvDdUDrta +clXvyFu1cvh43zcgTFeRc5JzrBh3Q4IgaezprClG5QtO+DdziZaKHG29777YtvTK +wP1H8K4LWCDFyB02rpeNUIMmJCn3nTsPBQIDAQABo2MwYTAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBRvjmKLk0Ow4UD2p8P98Q+4 +DxU4pTAdBgNVHQ4EFgQUb45ii5NDsOFA9qfD/fEPuA8VOKUwDQYJKoZIhvcNAQEL +BQADggIBAKwsKUF9+lz1GpUYvyypiqkkVHX1uECry6gkUSsYP2OprphWKwVDIqO3 +10aewCoSPY6WlkDfDDOLazeROpW7OSltwAJsipQLBwJNGD77+3v1dj2b9l4wBlgz +Hqp41eZUBDqyggmNzhYzWUUo8aWjlw5DI/0LIICQ/+Mmz7hkkeUFjxOgdg3XNwwQ +iJb0Pr6VvfHDffCjw3lHC1ySFWPtUnWK50Zpy1FVCypM9fJkT6lc/2cyjlUtMoIc +gC9qkfjLvH4YoiaoLqNTKIftV+Vlek4ASltOU8liNr3CjlvrzG4ngRhZi0Rjn9UM +ZfQpZX+RLOV/fuiJz48gy20HQhFRJjKKLjpHE7iNvUcNCfAWpO2Whi4Z2L6MOuhF +LhG6rlrnub+xzI/goP+4s9GFe3lmozm1O2bYQL7Pt2eLSMkZJVX8vY3PXtpOpvJp +zv1/THfQwUY1mFwjmwJFQ5Ra3bxHrSL+ul4vkSkphnsh3m5kt8sNjzdbowhq6/Td +Ao9QAwKxuDdollDruF/UKIqlIgyKhPBZLtU30WHlQnNYKoH3dtvi4k0NX/a3vgW0 +rk4N3hY9A4GzJl5LuEsAz/+MF7psYC0nhzck5npgL7XTgwSqT0N1osGDsieYK7EO +gLrAhV5Cud+xYJHT6xh+cHiudoO+cVrQkOPKwRYlZ0rwtnu64ZzZ +-----END CERTIFICATE-----`)) + + // TWCA CYBER Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIQQAE0jMIAAAAAAAAAATzyxjANBgkqhkiG9w0BAQwFADBQ +MQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290 +IENBMRswGQYDVQQDExJUV0NBIENZQkVSIFJvb3QgQ0EwHhcNMjIxMTIyMDY1NDI5 +WhcNNDcxMTIyMTU1OTU5WjBQMQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FO +LUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NBIENZQkVSIFJvb3Qg +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDG+Moe2Qkgfh1sTs6P +40czRJzHyWmqOlt47nDSkvgEs1JSHWdyKKHfi12VCv7qze33Kc7wb3+szT3vsxxF +avcokPFhV8UMxKNQXd7UtcsZyoC5dc4pztKFIuwCY8xEMCDa6pFbVuYdHNWdZsc/ +34bKS1PE2Y2yHer43CdTo0fhYcx9tbD47nORxc5zb87uEB8aBs/pJ2DFTxnk684i +JkXXYJndzk834H/nY62wuFm40AZoNWDTNq5xQwTxaWV4fPMf88oon1oglWa0zbfu +j3ikRRjpJi+NmykosaS3Om251Bw4ckVYsV7r8Cibt4LK/c/WMw+f+5eesRycnupf +Xtuq3VTpMCEobY5583WSjCb+3MX2w7DfRFlDo7YDKPYIMKoNM+HvnKkHIuNZW0CP +2oi3aQiotyMuRAlZN1vH4xfyIutuOVLF3lSnmMlLIJXcRolftBL5hSmO68gnFSDA +S9TMfAxsNAwmmyYxpjyn9tnQS6Jk/zuZQXLB4HCX8SS7K8R0IrGsayIyJNN4KsDA +oS/xUgXJP+92ZuJF2A09rZXIx4kmyA+upwMu+8Ff+iDhcK2wZSA3M2Cw1a/XDBzC +kHDXShi8fgGwsOsVHkQGzaRP6AzRwyAQ4VRlnrZR0Bp2a0JaWHY06rc3Ga4udfmW +5cFZ95RXKSWNOkyrTZpB0F8mAwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSdhWEUfMFib5do5E83QOGt4A1WNzAd +BgNVHQ4EFgQUnYVhFHzBYm+XaORPN0DhreANVjcwDQYJKoZIhvcNAQEMBQADggIB +AGSPesRiDrWIzLjHhg6hShbNcAu3p4ULs3a2D6f/CIsLJc+o1IN1KriWiLb73y0t +tGlTITVX1olNc79pj3CjYcya2x6a4CD4bLubIp1dhDGaLIrdaqHXKGnK/nZVekZn +68xDiBaiA9a5F/gZbG0jAn/xX9AKKSM70aoK7akXJlQKTcKlTfjF/biBzysseKNn +TKkHmvPfXvt89YnNdJdhEGoHK4Fa0o635yDRIG4kqIQnoVesqlVYL9zZyvpoBJ7t +RCT5dEA7IzOrg1oYJkK2bVS1FmAwbLGg+LhBoF1JSdJlBTrq/p1hvIbZv97Tujqx +f36SNI7JAG7cmL3c7IAFrQI932XtCwP39xaEBDG6k5TY8hL4iuO/Qq+n1M0RFxbI +Qh0UqEL20kCGoE8jypZFVmAGzbdVAaYBlGX+bgUJurSkquLvWL69J1bY73NxW0Qz +8ppy6rBePm6pUlvscG21h483XjyMnM7k8M4MZ0HMzvaAq07MTFb1wWFZk7Q+ptq4 +NxKfKjLji7gh7MMrZQzvIt6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzX +xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6 +t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X +-----END CERTIFICATE-----`)) + + // TWCA Global Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcx +EjAQBgNVBAoTCVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMT +VFdDQSBHbG9iYWwgUm9vdCBDQTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5 +NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQKEwlUQUlXQU4tQ0ExEDAOBgNVBAsT +B1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3QgQ0EwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2CnJfF +10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz +0ALfUPZVr2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfCh +MBwqoJimFb3u/Rk28OKRQ4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbH +zIh1HrtsBv+baz4X7GGqcXzGHaL3SekVtTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc +46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1WKKD+u4ZqyPpcC1jcxkt2 +yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99sy2sbZCi +laLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYP +oA/pyJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQA +BDzfuBSO6N+pjWxnkjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcE +qYSjMq+u7msXi7Kx/mzhkIyIqJdIzshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm +4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6gcFGn90xHNcgL +1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn +LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WF +H6vPNOw/KP4M8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNo +RI2T9GRwoD2dKAXDOXC4Ynsg/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+ +nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlglPx4mI88k1HtQJAH32RjJMtOcQWh +15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryPA9gK8kxkRr05YuWW +6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3mi4TW +nsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5j +wa19hAM8EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWz +aGHQRiapIVJpLesux+t3zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmy +KwbQBM0= +-----END CERTIFICATE-----`)) + + // TWCA Global Root CA G2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFlTCCA32gAwIBAgIQQAE0jMIAAAAAAAAAAZdY9DANBgkqhkiG9w0BAQwFADBU +MQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290 +IENBMR8wHQYDVQQDExZUV0NBIEdsb2JhbCBSb290IENBIEcyMB4XDTIyMTEyMjA2 +NDIyMVoXDTQ3MTEyMjE1NTk1OVowVDELMAkGA1UEBhMCVFcxEjAQBgNVBAoTCVRB +SVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEfMB0GA1UEAxMWVFdDQSBHbG9iYWwg +Um9vdCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKoO1SCS +Aa2C+QwIkTRrihbQRhb/A7jYjeqTNPv/K739bqrcm/KGgVX1iRzEjXVqWHiREx4C +E3A9774K5wCPuDHldMUwvv991pnlwkKjzyHWswh/kdVh5qKVEA3vXpcLSTjVIrDX +i1lvnzWbf9KRzHp/u6Cf3lUz9kuNCup9CcB53L1E4v4c52QhKM8ESuK0v4Z5KrsO +k8mPXqwwOVKQB7nqnCZCFMRnRv7RGmihPlAZoyYKJymQwva063OaeB7hmPRlDDUh +BvgL3mLlTcGzXdm5+mGXKuPqx0RVJJL+Eqc/xHfgLQKBB9X7feYQnjq0qO/s+1Dq +Nc/MfrtCuURsUum/KnIfP96bcOncWsU7u7/wWYWvL8GwFHkFrHWfJfURJwZgIcdt +Zb6oiZzlrEbf+F1EA41gvfexDcwv70FUL+5rlblOfDTfO/l3nX3NBz0cBjMSgOxy +nPItgtrVO8TH+QTDZAJ89TVgp7RGKS4b76VYgC56iVE4Njz9oXe4gDDQit6NpzQm +7CO7GFUYNkXu7QEGqk2/ZAzKmJcaMQJm+HhoW4jfCajnm/o0bXAcIa0Ii/Khtqx2 +ar/xgCUAvjweTa65PLaVY71rfkcSkFVFEY3sFx/BvieBk1djaQAmd4vDWeV70Q1E +8qjw94WaBffCLnCak4XYlZAxkFSm7AufN0UPAgMBAAGjYzBhMA4GA1UdDwEB/wQE +AwIBBjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFJKM1DbRW0dTxHENhN1k +KvU2ZEDnMB0GA1UdDgQWBBSSjNQ20VtHU8RxDYTdZCr1NmRA5zANBgkqhkiG9w0B +AQwFAAOCAgEAJfxL2pC02nXnQTqB0ab+oGrzGHFiaiQIi6l6TclVzs8QKC4EGZYF +z10CICo7s1U/Ac1CzbJ37f9183x325alz4xnBvSkm3L2IUkJmKMyXndaYwnvYkOX +Aji16jwYUGj8WVvZedTx5FZIE1bY03ELXniUOBFF+gUX9Q51HmJSYUa6LhmthrSI +D7FQ5kAANBqVnZPgUfnUVUbplTwlhi6X1wExGETsHGDpfWmvMviXQCUkto0aVTzF +t/e8BlI7cTBwPnEXfvFmBF5dvIoxQ6aSHXtU0qU2i2+N1l7a1MMuHd85VWCCMJ4n +/46A3WNMplU12NAzqYBtPl6dzKhngGb6mVcMUsoZdbA4NVUqgcWMHlbXX5DyINja +4GZx6bJ4q2e5JG5rNnL8b439f3I5KGdSkQUfV2XSo6cNYfqh59U1RpXJBof2MOwy +UamsVsAhTqMUdAU6vOO/bT1OP16lpG0pv4RRdVOOhhr1UXAqDRxOQOH9o+OlK2eQ +ksdsroW/OpsXFcqcKpPUTTkNvCAIo42IbAkNjK5EIU3JcezYJtcXni0RGDyjIn24 +J1S/aMg7QsyPXk7n3MLF+mpED41WiHrfiYRsoLM+PfFlAAmI6irrQM6zXawyF67B +m+nQwfVJlN2nznxaB+uuIJwXMJJpk3Lzmltxm/5q33owaY6zLtsPLN0= +-----END CERTIFICATE-----`)) + + // TWCA Root Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzES +MBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFU +V0NBIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMz +WhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJVEFJV0FO +LUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlm +aWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFE +AcK0HMMxQhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HH +K3XLfJ+utdGdIzdjp9xCoi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeX +RfwZVzsrb+RH9JlF/h3x+JejiB03HFyP4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/z +rX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1ry+UPizgN7gr8/g+YnzAx +3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkq +hkiG9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeC +MErJk/9q56YAf4lCmtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdls +XebQ79NqZp4VKIV66IIArB6nCWlWQtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62D +lhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVYT0bf+215WfKEIlKuD8z7fDvn +aspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocnyYh0igzyXxfkZ +YiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw== +-----END CERTIFICATE-----`)) + + // Telia Root CA v2 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIPAWdfJ9b+euPkrL4JWwWeMA0GCSqGSIb3DQEBCwUAMEQx +CzAJBgNVBAYTAkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UE +AwwQVGVsaWEgUm9vdCBDQSB2MjAeFw0xODExMjkxMTU1NTRaFw00MzExMjkxMTU1 +NTRaMEQxCzAJBgNVBAYTAkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZ +MBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2MjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBALLQPwe84nvQa5n44ndp586dpAO8gm2h/oFlH0wnrI4AuhZ76zBq +AMCzdGh+sq/H1WKzej9Qyow2RCRj0jbpDIX2Q3bVTKFgcmfiKDOlyzG4OiIjNLh9 +vVYiQJ3q9HsDrWj8soFPmNB06o3lfc1jw6P23pLCWBnglrvFxKk9pXSW/q/5iaq9 +lRdU2HhE8Qx3FZLgmEKnpNaqIJLNwaCzlrI6hEKNfdWV5Nbb6WLEWLN5xYzTNTOD +n3WhUidhOPFZPY5Q4L15POdslv5e2QJltI5c0BE0312/UqeBAMN/mUWZFdUXyApT +7GPzmX3MaRKGwhfwAZ6/hLzRUssbkmbOpFPlob/E2wnW5olWK8jjfN7j/4nlNW4o +6GwLI1GpJQXrSPjdscr6bAhR77cYbETKJuFzxokGgeWKrLDiKca5JLNrRBH0pUPC +TEPlcDaMtjNXepUugqD0XBCzYYP2AgWGLnwtbNwDRm41k9V6lS/eINhbfpSQBGq6 +WT0EBXWdN6IOLj3rwaRSg/7Qa9RmjtzG6RJOHSpXqhC8fF6CfaamyfItufUXJ63R +DolUK5X6wK0dmBR4M0KGCqlztft0DbcbMBnEWg4cJ7faGND/isgFuvGqHKI3t+ZI +pEYslOqodmJHixBTB0hXbOKSTbauBcvcwUpej6w9GU7C7WB1K9vBykLVAgMBAAGj +YzBhMB8GA1UdIwQYMBaAFHKs5DN5qkWH9v2sHZ7Wxy+G2CQ5MB0GA1UdDgQWBBRy +rOQzeapFh/b9rB2e1scvhtgkOTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw +AwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAoDtZpwmUPjaE0n4vOaWWl/oRrfxn83EJ +8rKJhGdEr7nv7ZbsnGTbMjBvZ5qsfl+yqwE2foH65IRe0qw24GtixX1LDoJt0nZi +0f6X+J8wfBj5tFJ3gh1229MdqfDBmgC9bXXYfef6xzijnHDoRnkDry5023X4blMM +A8iZGok1GTzTyVR8qPAs5m4HeW9q4ebqkYJpCh3DflminmtGFZhb069GHWLIzoBS +SRE/yQQSwxN8PzuKlts8oB4KtItUsiRnDe+Cy748fdHif64W1lZYudogsYMVoe+K +TTJvQS8TUoKU1xrBeKJR3Stwbbca+few4GeXVtt8YVMJAygCQMez2P2ccGrGKMOF +6eLtGpOg3kuYooQ+BXcBlj37tCAPnHICehIv1aO6UXivKitEZU61/Qrowc15h2Er +3oBXRb9n8ZuRXqWk7FlIEA04x7D6w0RtBPV4UBySllva9bguulvP5fBqnUsvWHMt +Ty3EHD70sz+rFQ47GUGKpMFXEmZxTPpT41frYpUJnlTd0cI8Vzy9OK2YZLe4A5pT +VmBds9hCG1xLEooc6+t9xnppxyd/pPiL8uSUZodL6ZQHCRJ5irLrdATczvREWeAW +ysUsWNc8e89ihmpQfTU2Zqf7N+cox9jQraVplI/owd8k+BsHMYeB2F326CjYSlKA +rBPuUBQemMc= +-----END CERTIFICATE-----`)) + + // TeliaSonera Root CA v1 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAw +NzEUMBIGA1UECgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJv +b3QgQ0EgdjEwHhcNMDcxMDE4MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYD +VQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwWVGVsaWFTb25lcmEgUm9vdCBDQSB2 +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+6yfwIaPzaSZVfp3F +VRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA3GV1 +7CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+X +Z75Ljo1kB1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+ +/jXh7VB7qTCNGdMJjmhnXb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs +81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxHoLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkm +dtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3F0fUTPHSiXk+TT2YqGHe +Oh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJoWjiUIMu +sDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4 +pgd7gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fs +slESl1MpWtTwEhDcTwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQ +arMCpgKIv7NHfirZ1fpoeDVNAgMBAAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYD +VR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qWDNXr+nuqF+gTEjANBgkqhkiG +9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNmzqjMDfz1mgbl +dxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx +0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1Tj +TQpgcmLNkQfWpb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBed +Y2gea+zDTYa4EzAvXUYNR0PVG6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7 +Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpcc41teyWRyu5FrgZLAMzTsVlQ2jqI +OylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOTJsjrDNYmiLbAJM+7 +vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2qReW +t88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcn +HL/EVlP6Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVx +SK236thZiNSQvxaz2emsWWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= +-----END CERTIFICATE-----`)) + + // TrustAsia Global Root CA G3 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUZPYOZXdhaqs7tOqFhLuxibhxkw8wDQYJKoZIhvcNAQEM +BQAwWjELMAkGA1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dp +ZXMsIEluYy4xJDAiBgNVBAMMG1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHMzAe +Fw0yMTA1MjAwMjEwMTlaFw00NjA1MTkwMjEwMTlaMFoxCzAJBgNVBAYTAkNOMSUw +IwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDDBtU +cnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzMwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDAMYJhkuSUGwoqZdC+BqmHO1ES6nBBruL7dOoKjbmzTNyPtxNS +T1QY4SxzlZHFZjtqz6xjbYdT8PfxObegQ2OwxANdV6nnRM7EoYNl9lA+sX4WuDqK +AtCWHwDNBSHvBm3dIZwZQ0WhxeiAysKtQGIXBsaqvPPW5vxQfmZCHzyLpnl5hkA1 +nyDvP+uLRx+PjsXUjrYsyUQE49RDdT/VP68czH5GX6zfZBCK70bwkPAPLfSIC7Ep +qq+FqklYqL9joDiR5rPmd2jE+SoZhLsO4fWvieylL1AgdB4SQXMeJNnKziyhWTXA +yB1GJ2Faj/lN03J5Zh6fFZAhLf3ti1ZwA0pJPn9pMRJpxx5cynoTi+jm9WAPzJMs +hH/x/Gr8m0ed262IPfN2dTPXS6TIi/n1Q1hPy8gDVI+lhXgEGvNz8teHHUGf59gX +zhqcD0r83ERoVGjiQTz+LISGNzzNPy+i2+f3VANfWdP3kXjHi3dqFuVJhZBFcnAv +kV34PmVACxmZySYgWmjBNb9Pp1Hx2BErW+Canig7CjoKH8GB5S7wprlppYiU5msT +f9FkPz2ccEblooV7WIQn3MSAPmeamseaMQ4w7OYXQJXZRe0Blqq/DPNL0WP3E1jA +uPP6Z92bfW1K/zJMtSU7/xxnD4UiWQWRkUF3gdCFTIcQcf+eQxuulXUtgQIDAQAB +o2MwYTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFEDk5PIj7zjKsK5Xf/Ih +MBY027ySMB0GA1UdDgQWBBRA5OTyI+84yrCuV3/yITAWNNu8kjAOBgNVHQ8BAf8E +BAMCAQYwDQYJKoZIhvcNAQEMBQADggIBACY7UeFNOPMyGLS0XuFlXsSUT9SnYaP4 +wM8zAQLpw6o1D/GUE3d3NZ4tVlFEbuHGLige/9rsR82XRBf34EzC4Xx8MnpmyFq2 +XFNFV1pF1AWZLy4jVe5jaN/TG3inEpQGAHUNcoTpLrxaatXeL1nHo+zSh2bbt1S1 +JKv0Q3jbSwTEb93mPmY+KfJLaHEih6D4sTNjduMNhXJEIlU/HHzp/LgV6FL6qj6j +ITk1dImmasI5+njPtqzn59ZW/yOSLlALqbUHM/Q4X6RJpstlcHboCoWASzY9M/eV +VHUl2qzEc4Jl6VL1XP04lQJqaTDFHApXB64ipCz5xUG3uOyfT0gA+QEEVcys+TIx +xHWVBqB/0Y0n3bOppHKH/lmLmnp0Ft0WpWIp6zqW3IunaFnT63eROfjXy9mPX1on +AX1daBli2MjN9LdyR75bl87yraKZk62Uy5P2EgmVtqvXO9A/EcswFi55gORngS1d +7XB4tmBZrOFdRWOPyN9yaFvqHbgB8X7754qz41SgOAngPN5C8sLtLpvzHzW2Ntjj +gKGLzZlkD8Kqq7HK9W+eQ42EVJmzbsASZthwEPEGNTNDqJwuuhQxzhB/HIbjj9LV ++Hfsm6vxL2PZQl/gZ4FkkfGXL/xuJvYz+NO1+MRiqzFRJQJ6+N1rZdVtTTDIZbpo +FGWsJwt0ivKH +-----END CERTIFICATE-----`)) + + // TrustAsia Global Root CA G4 + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICVTCCAdygAwIBAgIUTyNkuI6XY57GU4HBdk7LKnQV1tcwCgYIKoZIzj0EAwMw +WjELMAkGA1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMs +IEluYy4xJDAiBgNVBAMMG1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHNDAeFw0y +MTA1MjAwMjEwMjJaFw00NjA1MTkwMjEwMjJaMFoxCzAJBgNVBAYTAkNOMSUwIwYD +VQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDDBtUcnVz +dEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATx +s8045CVD5d4ZCbuBeaIVXxVjAd7Cq92zphtnS4CDr5nLrBfbK5bKfFJV4hrhPVbw +LxYI+hW8m7tH5j/uqOFMjPXTNvk4XatwmkcN4oFBButJ+bAp3TPsUKV/eSm4IJij +YzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUpbtKl86zK3+kMd6Xg1mD +pm9xy94wHQYDVR0OBBYEFKW7SpfOsyt/pDHel4NZg6ZvccveMA4GA1UdDwEB/wQE +AwIBBjAKBggqhkjOPQQDAwNnADBkAjBe8usGzEkxn0AAbbd+NvBNEU/zy4k6LHiR +UKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xkdUfFVZDj +/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA== +-----END CERTIFICATE-----`)) + + // TrustAsia SMIME ECC Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICNjCCAbugAwIBAgIUWsL4KU/jfcVeHRhvO5MgH/97ui0wCgYIKoZIzj0EAwMw +WjELMAkGA1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMs +IEluYy4xJDAiBgNVBAMTG1RydXN0QXNpYSBTTUlNRSBFQ0MgUm9vdCBDQTAeFw0y +NDA1MTUwNTQxNTlaFw00NDA1MTUwNTQxNThaMFoxCzAJBgNVBAYTAkNOMSUwIwYD +VQQKExxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDExtUcnVz +dEFzaWEgU01JTUUgRUNDIFJvb3QgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATN +2fsnvWnshsmQQ7FwF5SnyXcjOj8jZdMcox0eQlQg69BCu1m5i6zyho1Ljh2qliIj +OXZtkpvrIst6Q6Jz/XNLwiUPKrFpxv9F36k8lYC7qR5Kky/sHB2I9BGSN583mHKj +QjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFDFn5nKyDeYioKzPfiKnWTLj +ZiOlMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA3TpMjaTGf+29 +pcZPPv0xSyjWilbfZRZ3h037ujIIgeCeM0iLn5SG7wErlOaM1tSOAjEAn4GcsCb9 +K9by9XGEnqjHiozWWBFStbgEy8xxdWPixhk42W1sGXGkFhkhk7oGRChs +-----END CERTIFICATE-----`)) + + // TrustAsia SMIME RSA Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFhDCCA2ygAwIBAgIUWu5x394MV4W1uzYi17h2RgJzyv8wDQYJKoZIhvcNAQEM +BQAwWjELMAkGA1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dp +ZXMsIEluYy4xJDAiBgNVBAMTG1RydXN0QXNpYSBTTUlNRSBSU0EgUm9vdCBDQTAe +Fw0yNDA1MTUwNTQyMDFaFw00NDA1MTUwNTQyMDBaMFoxCzAJBgNVBAYTAkNOMSUw +IwYDVQQKExxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDExtU +cnVzdEFzaWEgU01JTUUgUlNBIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCYlZytPFlz05N2pkhUphyIckxN4YL/GhMfUN6M2ZBC0byZ0zej +13E6yt1eG5BhQm6PQAFzfR8xutQdbgTSqpCESjMKRJ9aGR+0bi1o/K/An0oQEr8+ +gsKCsC/nkG+QZBCD7Ow2lAx8T+ACDT2HeUJNAOUwrnAfFf36z1IlNk15ILvxEJjg +YIfJ9XgMIu0C5hFs8ZtakRF0htD+eJKWBMOY78Zwr6mQqhb2Iu3Y+kYoceLJCMBQ +vHajui2W8hH5pL0QVvgnbStLZIjcF13PAAiKkq4azBLX3/AQKPPNOuo6Eowb52EJ +Q2rkOOn+dDnbzQo7w09T1q5x1TiDhx/O50zzEVWH37ev9+sahhBtqO1I3TLQ26oq +C3J3KXf9eug/eCAqaL7ebwjmtYVHgDf5cZaLpZhWl3wRZRaO1M7YJ9T5WsWnjbvR +Nw2lq2Vd2nSTiF7bdfZ/m8KasW0IAgyYSrvNMK92NQKFViNRCUAJBffwPR7CyHoa +usVBFbkNdrS0pLhF/Y2jOz0DKs2zlX80e92hT9k6/yf1DcIBnP9ZdVoayefS/X9P +D7X+DTzmoNb7tXZctDBNED/+4utaDrFPT1B+CDMCkVcySYmnQBBQF2ufY7qyslaY +dvT/cukEnNSnTE/2Oh9aVDFvy7oyrfhtr0XHe2NE38L9eOhKirB0dRbejwIDAQAB +o0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSAGqpDwcl/ixqWRbw9u2tI +UmxbqzAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggIBACp1gaGCIOp/ +Vq4JMJcQePTZQBRSpO5qf/AJKNYQY+BOe8kxxwilF+uvhuKXB0+pDqKFzO2kgIEd +WlMGPEwaqbeEhs989YUKcJnQ7TaRjed3Ls6EnCiGLSU1jEwB5n3bYV3id4TTAdFi +3QyiCmSk/PDtOkjyOew11qF6F3Hs09LsuCb7rRVwVkrPZMC5YFv35s2gwgMr+bLl +2rqlNxzYjdp5dCpn5KJ6xyyNpcFqgWzM9ak5aiJ9ouIIzemT27rLH3V3nveYrxTk +O6BMp3LntV5TScz/klfxWSsJuulSk8APRQth1mxZcwvY+QEv2gNPNxz034NeC0Gg +sXw5AKFs0Ni0kXIrGz+imtHE3yvVyJV9hM12G9zkJMY/FSI9hadCK+1+cVlhSMI9 +kWNAfCmzgBYKJfwYYA5TrQ4qzvxBOs2x5GprzDltyE1luKqTiHhuDwKL4JaOdB/Q +fuF0t/aBauQjrI79jzUdmnEKTypVL/4YwQD3e0iKZa9vCB1D51q4H6ToA+v9TLW0 +k6gx3kOdEr3n6aTS32/8b0aj7zFOjRerG6ng+Kk0VqEO53TsqIeF2Hc1S40+bnJ8 +SMwfcrNxdNQkhrzIwON5FAHO2fqBxlyz+V0MOL7O8o6NXz0l4VE5I6jqAI4Es79y +oMK6g/vNpJd1IJq/p1Di3a0sH/Q/o8gx +-----END CERTIFICATE-----`)) + + // TrustAsia TLS ECC Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICMTCCAbegAwIBAgIUNnThTXxlE8msg1UloD5Sfi9QaMcwCgYIKoZIzj0EAwMw +WDELMAkGA1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMs +IEluYy4xIjAgBgNVBAMTGVRydXN0QXNpYSBUTFMgRUNDIFJvb3QgQ0EwHhcNMjQw +NTE1MDU0MTU2WhcNNDQwNTE1MDU0MTU1WjBYMQswCQYDVQQGEwJDTjElMCMGA1UE +ChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEiMCAGA1UEAxMZVHJ1c3RB +c2lhIFRMUyBFQ0MgUm9vdCBDQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLh/pVs/ +AT598IhtrimY4ZtcU5nb9wj/1WrgjstEpvDBjL1P1M7UiFPoXlfXTr4sP/MSpwDp +guMqWzJ8S5sUKZ74LYO1644xST0mYekdcouJtgq7nDM1D9rs3qlKH8kzsaNCMEAw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQULIVTu7FDzTLqnqOH/qKYqKaT6RAw +DgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMFRH18MtYYZI9HlaVQ01 +L18N9mdsd0AaRuf4aFtOJx24mH1/k78ITcTaRTChD15KeAIxAKORh/IRM4PDwYqR +OkwrULG9IpRdNYlzg8WbGf60oenUoWa2AaU2+dhoYSi3dOGiMQ== +-----END CERTIFICATE-----`)) + + // TrustAsia TLS RSA Root CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIFgDCCA2igAwIBAgIUHBjYz+VTPyI1RlNUJDxsR9FcSpwwDQYJKoZIhvcNAQEM +BQAwWDELMAkGA1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dp +ZXMsIEluYy4xIjAgBgNVBAMTGVRydXN0QXNpYSBUTFMgUlNBIFJvb3QgQ0EwHhcN +MjQwNTE1MDU0MTU3WhcNNDQwNTE1MDU0MTU2WjBYMQswCQYDVQQGEwJDTjElMCMG +A1UEChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEiMCAGA1UEAxMZVHJ1 +c3RBc2lhIFRMUyBSU0EgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +AgoCggIBAMMWuBtqpERz5dZO9LnPWwvB0ZqB9WOwj0PBuwhaGnrhB3YmH49pVr7+ +NmDQDIPNlOrnxS1cLwUWAp4KqC/lYCZUlviYQB2srp10Zy9U+5RjmOMmSoPGlbYJ +Q1DNDX3eRA5gEk9bNb2/mThtfWza4mhzH/kxpRkQcwUqwzIZheo0qt1CHjCNP561 +HmHVb70AcnKtEj+qpklz8oYVlQwQX1Fkzv93uMltrOXVmPGZLmzjyUT5tUMnCE32 +ft5EebuyjBza00tsLtbDeLdM1aTk2tyKjg7/D8OmYCYozza/+lcK7Fs/6TAWe8Tb +xNRkoDD75f0dcZLdKY9BWN4ArTr9PXwaqLEX8E40eFgl1oUh63kd0Nyrz2I8sMeX +i9bQn9P+PN7F4/w6g3CEIR0JwqH8uyghZVNgepBtljhb//HXeltt08lwSUq6HTrQ +UNoyIBnkiz/r1RYmNzz7dZ6wB3C4FGB33PYPXFIKvF1tjVEK2sUYyJtt3LCDs3+j +TnhMmCWr8n4uIF6CFabW2I+s5c0yhsj55NqJ4js+k8UTav/H9xj8Z7XvGCxUq0DT +bE3txci3OE9kxJRMT6DNrqXGJyV1J23G2pyOsAWZ1SgRxSHUuPzHlqtKZFlhaxP8 +S8ySpg+kUb8OWJDZgoM5pl+z+m6Ss80zDoWo8SnTq1mt1tve1CuBAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLgHkXlcBvRG/XtZylomkadFK/hT +MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwFAAOCAgEAIZtqBSBdGBanEqT3 +Rz/NyjuujsCCztxIJXgXbODgcMTWltnZ9r96nBO7U5WS/8+S4PPFJzVXqDuiGev4 +iqME3mmL5Dw8veWv0BIb5Ylrc5tvJQJLkIKvQMKtuppgJFqBTQUYo+IzeXoLH5Pt +7DlK9RME7I10nYEKqG/odv6LTytpEoYKNDbdgptvT+Bz3Ul/KD7JO6NXBNiT2Twp +2xIQaOHEibgGIOcberyxk2GaGUARtWqFVwHxtlotJnMnlvm5P1vQiJ3koP26TpUJ +g3933FEFlJ0gcXax7PqJtZwuhfG5WyRasQmr2soaB82G39tp27RIGAAtvKLEiUUj +pQ7hRGU+isFqMB3iYPg6qocJQrmBktwliJiJ8Xw18WLK7nn4GS/+X/jbh87qqA8M +pugLoDzga5SYnH+tBuYc6kIQX+ImFTw3OffXvO645e8D7r0i+yiGNFjEWn9hongP +XvPKnbwbPKfILfanIhHKA9jnZwqKDss1jjQ52MjqjZ9k4DewbNfFj8GQYSbbJIwe +SsCI3zWQzj8C9GRh3sfIB5XeMhg6j6JCQCTl1jNdfK7vsU1P1FeQNWrcrgSXSYk0 +ly4wBOeY99sLAZDBHwo/+ML+TvrbmnNzFrwFuHnYWa8G5z9nODmxfKuU4CkUpijy +323imttUQ/hHWKNddBWcwauwxzQ= +-----END CERTIFICATE-----`)) + + // Secure Global CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK +MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x +GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx +MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg +Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ +iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa +/FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ +jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI +HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7 +sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w +gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw +KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG +AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L +URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO +H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm +I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY +iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc +f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW +-----END CERTIFICATE-----`)) + + // SecureTrust CA + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI +MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x +FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz +MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv +cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz +Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO +0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao +wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj +7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS +8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT +BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg +JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3 +6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/ +3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm +D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS +CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR +3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= +-----END CERTIFICATE-----`)) + + // Trustwave Global Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQsw +CQYDVQQGEwJVUzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28x +ITAfBgNVBAoMGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1 +c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMx +OTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJVUzERMA8GA1UECAwI +SWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2ZSBI +b2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +ALldUShLPDeS0YLOvR29zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0Xzn +swuvCAAJWX/NKSqIk4cXGIDtiLK0thAfLdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu +7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4BqstTnoApTAbqOl5F2brz8 +1Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9oWN0EACyW +80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotP +JqX+OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1l +RtzuzWniTY+HKE40Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfw +hI0Vcnyh78zyiGG69Gm7DIwLdVcEuE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10 +coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm+9jaJXLE9gCxInm943xZYkqc +BW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqjifLJS3tBEW1n +twiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1Ud +DwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W +0OhUKDtkLSGm+J1WE2pIPU/HPinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfe +uyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0HZJDmHvUqoai7PF35owgLEQzxPy0Q +lG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla4gt5kNdXElE1GYhB +aCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5RvbbE +sLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPT +MaCm/zjdzyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qe +qu5AvzSxnI9O4fKSTx+O856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxh +VicGaeVyQYHTtgGJoC86cnn+OjC/QezHYj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8 +h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu3R3y4G5OBVixwJAWKqQ9 +EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP29FpHOTK +yeC2nOnOcXHebD8WpHk= +-----END CERTIFICATE-----`)) + + // Trustwave Global ECC P256 Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAf +BgNVBAoTGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3 +YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0x +NzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYDVQQGEwJVUzERMA8G +A1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0 +d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBF +Q0MgUDI1NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABH77bOYj43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoN +FWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqmP62jQzBBMA8GA1UdEwEB/wQFMAMBAf8w +DwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt0UrrdaVKEJmzsaGLSvcw +CgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjzRM4q3wgh +DDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7 +-----END CERTIFICATE-----`)) + + // Trustwave Global ECC P384 Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAf +BgNVBAoTGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3 +YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0x +NzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYDVQQGEwJVUzERMA8G +A1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0 +d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBF +Q0MgUDM4NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuB +BAAiA2IABGvaDXU1CDFHBa5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJ +j9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr/TklZvFe/oyujUF5nQlgziip04pt89ZF +1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHQ8BAf8EBQMDBwYAMB0G +A1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNnADBkAjA3 +AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsC +MGclCrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVu +Sw== +-----END CERTIFICATE-----`)) + + // XRamp Global Certification Authority + mozillaIncluded.AppendCertsFromPEM([]byte(`-----BEGIN CERTIFICATE----- +MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCB +gjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEk +MCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRY +UmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQxMTAxMTcx +NDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3 +dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2Vy +dmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS6 +38eMpSe2OAtp87ZOqCwuIR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCP +KZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMxfoArtYzAQDsRhtDLooY2YKTVMIJt2W7Q +DxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FEzG+gSqmUsE3a56k0enI4 +qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqsAxcZZPRa +JSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNVi +PvryxS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0P +BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASs +jVy16bYbMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0 +eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQEwDQYJKoZIhvcNAQEFBQAD +ggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc/Kh4ZzXxHfAR +vbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt +qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLa +IR9NmXmd4c8nnxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSy +i6mx5O+aGtA9aZnuqCij4Tyz8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQ +O+7ETPTsJ3xCwnR8gooJybQDJbw= +-----END CERTIFICATE-----`)) +} diff --git a/common/certificate/store.go b/common/certificate/store.go new file mode 100644 index 00000000..cfced463 --- /dev/null +++ b/common/certificate/store.go @@ -0,0 +1,192 @@ +package certificate + +import ( + "context" + "crypto/x509" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" +) + +var _ adapter.CertificateStore = (*Store)(nil) + +type Store struct { + access sync.RWMutex + systemPool *x509.CertPool + currentPool *x509.CertPool + certificate string + certificatePaths []string + certificateDirectoryPaths []string + watcher *fswatch.Watcher +} + +func NewStore(ctx context.Context, logger logger.Logger, options option.CertificateOptions) (*Store, error) { + var systemPool *x509.CertPool + switch options.Store { + case C.CertificateStoreSystem, "": + systemPool = x509.NewCertPool() + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + var systemValid bool + if platformInterface != nil { + for _, cert := range platformInterface.SystemCertificates() { + if systemPool.AppendCertsFromPEM([]byte(cert)) { + systemValid = true + } + } + } + if !systemValid { + certPool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + systemPool = certPool + } + case C.CertificateStoreMozilla: + systemPool = mozillaIncluded + case C.CertificateStoreChrome: + systemPool = chromeIncluded + case C.CertificateStoreNone: + systemPool = nil + default: + return nil, E.New("unknown certificate store: ", options.Store) + } + store := &Store{ + systemPool: systemPool, + certificate: strings.Join(options.Certificate, "\n"), + certificatePaths: options.CertificatePath, + certificateDirectoryPaths: options.CertificateDirectoryPath, + } + var watchPaths []string + for _, target := range options.CertificatePath { + watchPaths = append(watchPaths, target) + } + for _, target := range options.CertificateDirectoryPath { + watchPaths = append(watchPaths, target) + } + if len(watchPaths) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: watchPaths, + Logger: logger, + Callback: func(_ string) { + err := store.update() + if err != nil { + logger.Error(E.Cause(err, "reload certificates")) + } + }, + }) + if err != nil { + return nil, E.Cause(err, "fswatch: create fsnotify watcher") + } + store.watcher = watcher + } + err := store.update() + if err != nil { + return nil, E.Cause(err, "initializing certificate store") + } + return store, nil +} + +func (s *Store) Name() string { + return "certificate" +} + +func (s *Store) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if s.watcher != nil { + return s.watcher.Start() + } + return nil +} + +func (s *Store) Close() error { + if s.watcher != nil { + return s.watcher.Close() + } + return nil +} + +func (s *Store) Pool() *x509.CertPool { + s.access.RLock() + defer s.access.RUnlock() + return s.currentPool +} + +func (s *Store) update() error { + s.access.Lock() + defer s.access.Unlock() + var currentPool *x509.CertPool + if s.systemPool == nil { + currentPool = x509.NewCertPool() + } else { + currentPool = s.systemPool.Clone() + } + if s.certificate != "" { + if !currentPool.AppendCertsFromPEM([]byte(s.certificate)) { + return E.New("invalid certificate PEM strings") + } + } + for _, path := range s.certificatePaths { + pemContent, err := os.ReadFile(path) + if err != nil { + return err + } + if !currentPool.AppendCertsFromPEM(pemContent) { + return E.New("invalid certificate PEM file: ", path) + } + } + var firstErr error + for _, directoryPath := range s.certificateDirectoryPaths { + directoryEntries, err := readUniqueDirectoryEntries(directoryPath) + if err != nil { + if firstErr == nil && !os.IsNotExist(err) { + firstErr = E.Cause(err, "invalid certificate directory: ", directoryPath) + } + continue + } + for _, directoryEntry := range directoryEntries { + pemContent, err := os.ReadFile(filepath.Join(directoryPath, directoryEntry.Name())) + if err == nil { + currentPool.AppendCertsFromPEM(pemContent) + } + } + } + if firstErr != nil { + return firstErr + } + s.currentPool = currentPool + return nil +} + +func readUniqueDirectoryEntries(dir string) ([]fs.DirEntry, error) { + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + uniq := files[:0] + for _, f := range files { + if !isSameDirSymlink(f, dir) { + uniq = append(uniq, f) + } + } + return uniq, nil +} + +func isSameDirSymlink(f fs.DirEntry, dir string) bool { + if f.Type()&fs.ModeSymlink == 0 { + return false + } + target, err := os.Readlink(filepath.Join(dir, f.Name())) + return err == nil && !strings.Contains(target, "/") +} diff --git a/common/compatible/map.go b/common/compatible/map.go new file mode 100644 index 00000000..199edd89 --- /dev/null +++ b/common/compatible/map.go @@ -0,0 +1,58 @@ +package compatible + +import "sync" + +// Map is a generics sync.Map +type Map[K comparable, V any] struct { + m sync.Map +} + +func (m *Map[K, V]) Len() int { + var count int + m.m.Range(func(key, value any) bool { + count++ + return true + }) + return count +} + +func (m *Map[K, V]) Load(key K) (V, bool) { + v, ok := m.m.Load(key) + if !ok { + return *new(V), false + } + + return v.(V), ok +} + +func (m *Map[K, V]) Store(key K, value V) { + m.m.Store(key, value) +} + +func (m *Map[K, V]) Delete(key K) { + m.m.Delete(key) +} + +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value any) bool { + return f(key.(K), value.(V)) + }) +} + +func (m *Map[K, V]) LoadOrStore(key K, value V) (V, bool) { + v, ok := m.m.LoadOrStore(key, value) + return v.(V), ok +} + +func (m *Map[K, V]) LoadAndDelete(key K) (V, bool) { + v, ok := m.m.LoadAndDelete(key) + if !ok { + return *new(V), false + } + + return v.(V), ok +} + +func New[K comparable, V any]() *Map[K, V] { + return &Map[K, V]{m: sync.Map{}} +} diff --git a/common/convertor/adguard/convertor.go b/common/convertor/adguard/convertor.go new file mode 100644 index 00000000..3e6d0254 --- /dev/null +++ b/common/convertor/adguard/convertor.go @@ -0,0 +1,458 @@ +package adguard + +import ( + "bufio" + "bytes" + "io" + "net/netip" + "os" + "strconv" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" +) + +type agdguardRuleLine struct { + ruleLine string + isRawDomain bool + isExclude bool + isSuffix bool + hasStart bool + hasEnd bool + isRegexp bool + isImportant bool +} + +func ToOptions(reader io.Reader, logger logger.Logger) ([]option.HeadlessRule, error) { + scanner := bufio.NewScanner(reader) + var ( + ruleLines []agdguardRuleLine + ignoredLines int + ) +parseLine: + for scanner.Scan() { + ruleLine := scanner.Text() + if ruleLine == "" { + continue + } + if strings.HasPrefix(ruleLine, "!") || strings.HasPrefix(ruleLine, "#") { + continue + } + originRuleLine := ruleLine + if M.IsDomainName(ruleLine) { + ruleLines = append(ruleLines, agdguardRuleLine{ + ruleLine: ruleLine, + isRawDomain: true, + }) + continue + } + hostLine, err := parseAdGuardHostLine(ruleLine) + if err == nil { + if hostLine != "" { + ruleLines = append(ruleLines, agdguardRuleLine{ + ruleLine: hostLine, + isRawDomain: true, + hasStart: true, + hasEnd: true, + }) + } + continue + } + if strings.HasSuffix(ruleLine, "|") { + ruleLine = ruleLine[:len(ruleLine)-1] + } + var ( + isExclude bool + isSuffix bool + hasStart bool + hasEnd bool + isRegexp bool + isImportant bool + ) + if !strings.HasPrefix(ruleLine, "/") && strings.Contains(ruleLine, "$") { + params := common.SubstringAfter(ruleLine, "$") + for _, param := range strings.Split(params, ",") { + paramParts := strings.Split(param, "=") + var ignored bool + if len(paramParts) > 0 && len(paramParts) <= 2 { + switch paramParts[0] { + case "app", "network": + // maybe support by package_name/process_name + case "dnstype": + // maybe support by query_type + case "important": + ignored = true + isImportant = true + case "dnsrewrite": + if len(paramParts) == 2 && M.ParseAddr(paramParts[1]).IsUnspecified() { + ignored = true + } + } + } + if !ignored { + ignoredLines++ + logger.Debug("ignored unsupported rule with modifier: ", paramParts[0], ": ", originRuleLine) + continue parseLine + } + } + ruleLine = common.SubstringBefore(ruleLine, "$") + } + if strings.HasPrefix(ruleLine, "@@") { + ruleLine = ruleLine[2:] + isExclude = true + } + if strings.HasSuffix(ruleLine, "|") { + ruleLine = ruleLine[:len(ruleLine)-1] + } + if strings.HasPrefix(ruleLine, "||") { + ruleLine = ruleLine[2:] + isSuffix = true + } else if strings.HasPrefix(ruleLine, "|") { + ruleLine = ruleLine[1:] + hasStart = true + } + if strings.HasSuffix(ruleLine, "^") { + ruleLine = ruleLine[:len(ruleLine)-1] + hasEnd = true + } + if strings.HasPrefix(ruleLine, "/") && strings.HasSuffix(ruleLine, "/") { + ruleLine = ruleLine[1 : len(ruleLine)-1] + if ignoreIPCIDRRegexp(ruleLine) { + ignoredLines++ + logger.Debug("ignored unsupported rule with IPCIDR regexp: ", originRuleLine) + continue + } + isRegexp = true + } else { + if strings.Contains(ruleLine, "://") { + ruleLine = common.SubstringAfter(ruleLine, "://") + isSuffix = true + } + if strings.Contains(ruleLine, "/") { + ignoredLines++ + logger.Debug("ignored unsupported rule with path: ", originRuleLine) + continue + } + if strings.Contains(ruleLine, "?") || strings.Contains(ruleLine, "&") { + ignoredLines++ + logger.Debug("ignored unsupported rule with query: ", originRuleLine) + continue + } + if strings.Contains(ruleLine, "[") || strings.Contains(ruleLine, "]") || + strings.Contains(ruleLine, "(") || strings.Contains(ruleLine, ")") || + strings.Contains(ruleLine, "!") || strings.Contains(ruleLine, "#") { + ignoredLines++ + logger.Debug("ignored unsupported cosmetic filter: ", originRuleLine) + continue + } + if strings.Contains(ruleLine, "~") { + ignoredLines++ + logger.Debug("ignored unsupported rule modifier: ", originRuleLine) + continue + } + var domainCheck string + if strings.HasPrefix(ruleLine, ".") || strings.HasPrefix(ruleLine, "-") { + domainCheck = "r" + ruleLine + } else { + domainCheck = ruleLine + } + if ruleLine == "" { + ignoredLines++ + logger.Debug("ignored unsupported rule with empty domain", originRuleLine) + continue + } else { + domainCheck = strings.ReplaceAll(domainCheck, "*", "x") + if !M.IsDomainName(domainCheck) { + _, ipErr := parseADGuardIPCIDRLine(ruleLine) + if ipErr == nil { + ignoredLines++ + logger.Debug("ignored unsupported rule with IPCIDR: ", originRuleLine) + continue + } + if M.ParseSocksaddr(domainCheck).Port != 0 { + logger.Debug("ignored unsupported rule with port: ", originRuleLine) + } else { + logger.Debug("ignored unsupported rule with invalid domain: ", originRuleLine) + } + ignoredLines++ + continue + } + } + } + ruleLines = append(ruleLines, agdguardRuleLine{ + ruleLine: ruleLine, + isExclude: isExclude, + isSuffix: isSuffix, + hasStart: hasStart, + hasEnd: hasEnd, + isRegexp: isRegexp, + isImportant: isImportant, + }) + } + if len(ruleLines) == 0 { + return nil, E.New("AdGuard rule-set is empty or all rules are unsupported") + } + if common.All(ruleLines, func(it agdguardRuleLine) bool { + return it.isRawDomain + }) { + return []option.HeadlessRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + Domain: common.Map(ruleLines, func(it agdguardRuleLine) string { + return it.ruleLine + }), + }, + }, + }, nil + } + mapDomain := func(it agdguardRuleLine) string { + ruleLine := it.ruleLine + if it.isSuffix { + ruleLine = "||" + ruleLine + } else if it.hasStart { + ruleLine = "|" + ruleLine + } + if it.hasEnd { + ruleLine += "^" + } + return ruleLine + } + + importantDomain := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return it.isImportant && !it.isRegexp && !it.isExclude }), mapDomain) + importantDomainRegex := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return it.isImportant && it.isRegexp && !it.isExclude }), mapDomain) + importantExcludeDomain := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return it.isImportant && !it.isRegexp && it.isExclude }), mapDomain) + importantExcludeDomainRegex := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return it.isImportant && it.isRegexp && it.isExclude }), mapDomain) + domain := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return !it.isImportant && !it.isRegexp && !it.isExclude }), mapDomain) + domainRegex := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return !it.isImportant && it.isRegexp && !it.isExclude }), mapDomain) + excludeDomain := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return !it.isImportant && !it.isRegexp && it.isExclude }), mapDomain) + excludeDomainRegex := common.Map(common.Filter(ruleLines, func(it agdguardRuleLine) bool { return !it.isImportant && it.isRegexp && it.isExclude }), mapDomain) + currentRule := option.HeadlessRule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + AdGuardDomain: domain, + DomainRegex: domainRegex, + }, + } + if len(excludeDomain) > 0 || len(excludeDomainRegex) > 0 { + currentRule = option.HeadlessRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalHeadlessRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.HeadlessRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + AdGuardDomain: excludeDomain, + DomainRegex: excludeDomainRegex, + Invert: true, + }, + }, + currentRule, + }, + }, + } + } + if len(importantDomain) > 0 || len(importantDomainRegex) > 0 { + currentRule = option.HeadlessRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalHeadlessRule{ + Mode: C.LogicalTypeOr, + Rules: []option.HeadlessRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + AdGuardDomain: importantDomain, + DomainRegex: importantDomainRegex, + }, + }, + currentRule, + }, + }, + } + } + if len(importantExcludeDomain) > 0 || len(importantExcludeDomainRegex) > 0 { + currentRule = option.HeadlessRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalHeadlessRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.HeadlessRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + AdGuardDomain: importantExcludeDomain, + DomainRegex: importantExcludeDomainRegex, + Invert: true, + }, + }, + currentRule, + }, + }, + } + } + if ignoredLines > 0 { + logger.Info("parsed rules: ", len(ruleLines), "/", len(ruleLines)+ignoredLines) + } + return []option.HeadlessRule{currentRule}, nil +} + +var ErrInvalid = E.New("invalid binary AdGuard rule-set") + +func FromOptions(rules []option.HeadlessRule) ([]byte, error) { + if len(rules) != 1 { + return nil, ErrInvalid + } + rule := rules[0] + var ( + importantDomain []string + importantDomainRegex []string + importantExcludeDomain []string + importantExcludeDomainRegex []string + domain []string + domainRegex []string + excludeDomain []string + excludeDomainRegex []string + ) +parse: + for { + switch rule.Type { + case C.RuleTypeLogical: + if !(len(rule.LogicalOptions.Rules) == 2 && rule.LogicalOptions.Rules[0].Type == C.RuleTypeDefault) { + return nil, ErrInvalid + } + if rule.LogicalOptions.Mode == C.LogicalTypeAnd && rule.LogicalOptions.Rules[0].DefaultOptions.Invert { + if len(importantExcludeDomain) == 0 && len(importantExcludeDomainRegex) == 0 { + importantExcludeDomain = rule.LogicalOptions.Rules[0].DefaultOptions.AdGuardDomain + importantExcludeDomainRegex = rule.LogicalOptions.Rules[0].DefaultOptions.DomainRegex + if len(importantExcludeDomain)+len(importantExcludeDomainRegex) == 0 { + return nil, ErrInvalid + } + } else { + excludeDomain = rule.LogicalOptions.Rules[0].DefaultOptions.AdGuardDomain + excludeDomainRegex = rule.LogicalOptions.Rules[0].DefaultOptions.DomainRegex + if len(excludeDomain)+len(excludeDomainRegex) == 0 { + return nil, ErrInvalid + } + } + } else if rule.LogicalOptions.Mode == C.LogicalTypeOr && !rule.LogicalOptions.Rules[0].DefaultOptions.Invert { + importantDomain = rule.LogicalOptions.Rules[0].DefaultOptions.AdGuardDomain + importantDomainRegex = rule.LogicalOptions.Rules[0].DefaultOptions.DomainRegex + if len(importantDomain)+len(importantDomainRegex) == 0 { + return nil, ErrInvalid + } + } else { + return nil, ErrInvalid + } + rule = rule.LogicalOptions.Rules[1] + case C.RuleTypeDefault: + domain = rule.DefaultOptions.AdGuardDomain + domainRegex = rule.DefaultOptions.DomainRegex + if len(domain)+len(domainRegex) == 0 { + return nil, ErrInvalid + } + break parse + } + } + var output bytes.Buffer + for _, ruleLine := range importantDomain { + output.WriteString(ruleLine) + output.WriteString("$important\n") + } + for _, ruleLine := range importantDomainRegex { + output.WriteString("/") + output.WriteString(ruleLine) + output.WriteString("/$important\n") + + } + for _, ruleLine := range importantExcludeDomain { + output.WriteString("@@") + output.WriteString(ruleLine) + output.WriteString("$important\n") + } + for _, ruleLine := range importantExcludeDomainRegex { + output.WriteString("@@/") + output.WriteString(ruleLine) + output.WriteString("/$important\n") + } + for _, ruleLine := range domain { + output.WriteString(ruleLine) + output.WriteString("\n") + } + for _, ruleLine := range domainRegex { + output.WriteString("/") + output.WriteString(ruleLine) + output.WriteString("/\n") + } + for _, ruleLine := range excludeDomain { + output.WriteString("@@") + output.WriteString(ruleLine) + output.WriteString("\n") + } + for _, ruleLine := range excludeDomainRegex { + output.WriteString("@@/") + output.WriteString(ruleLine) + output.WriteString("/\n") + } + return output.Bytes(), nil +} + +func ignoreIPCIDRRegexp(ruleLine string) bool { + if strings.HasPrefix(ruleLine, "(http?:\\/\\/)") { + ruleLine = ruleLine[12:] + } else if strings.HasPrefix(ruleLine, "(https?:\\/\\/)") { + ruleLine = ruleLine[13:] + } else if strings.HasPrefix(ruleLine, "^") { + ruleLine = ruleLine[1:] + } + return common.Error(strconv.ParseUint(common.SubstringBefore(ruleLine, "\\."), 10, 8)) == nil || + common.Error(strconv.ParseUint(common.SubstringBefore(ruleLine, "."), 10, 8)) == nil +} + +func parseAdGuardHostLine(ruleLine string) (string, error) { + idx := strings.Index(ruleLine, " ") + if idx == -1 { + return "", os.ErrInvalid + } + address, err := netip.ParseAddr(ruleLine[:idx]) + if err != nil { + return "", err + } + if !address.IsUnspecified() { + return "", nil + } + domain := ruleLine[idx+1:] + if !M.IsDomainName(domain) { + return "", E.New("invalid domain name: ", domain) + } + return domain, nil +} + +func parseADGuardIPCIDRLine(ruleLine string) (netip.Prefix, error) { + var isPrefix bool + if strings.HasSuffix(ruleLine, ".") { + isPrefix = true + ruleLine = ruleLine[:len(ruleLine)-1] + } + ruleStringParts := strings.Split(ruleLine, ".") + if len(ruleStringParts) > 4 || len(ruleStringParts) < 4 && !isPrefix { + return netip.Prefix{}, os.ErrInvalid + } + ruleParts := make([]uint8, 0, len(ruleStringParts)) + for _, part := range ruleStringParts { + rulePart, err := strconv.ParseUint(part, 10, 8) + if err != nil { + return netip.Prefix{}, err + } + ruleParts = append(ruleParts, uint8(rulePart)) + } + bitLen := len(ruleParts) * 8 + for len(ruleParts) < 4 { + ruleParts = append(ruleParts, 0) + } + return netip.PrefixFrom(netip.AddrFrom4([4]byte(ruleParts)), bitLen), nil +} diff --git a/common/convertor/adguard/convertor_test.go b/common/convertor/adguard/convertor_test.go new file mode 100644 index 00000000..b91d9bef --- /dev/null +++ b/common/convertor/adguard/convertor_test.go @@ -0,0 +1,145 @@ +package adguard + +import ( + "context" + "strings" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing/common/logger" + + "github.com/stretchr/testify/require" +) + +func TestConverter(t *testing.T) { + t.Parallel() + ruleString := `||sagernet.org^$important +@@|sing-box.sagernet.org^$important +||example.org^ +|example.com^ +example.net^ +||example.edu +||example.edu.tw^ +|example.gov +example.arpa +@@|sagernet.example.org^ +` + rules, err := ToOptions(strings.NewReader(ruleString), logger.NOP()) + require.NoError(t, err) + require.Len(t, rules, 1) + rule, err := rule.NewHeadlessRule(context.Background(), rules[0]) + require.NoError(t, err) + matchDomain := []string{ + "example.org", + "www.example.org", + "example.com", + "example.net", + "isexample.net", + "www.example.net", + "example.edu", + "example.edu.cn", + "example.edu.tw", + "www.example.edu", + "www.example.edu.cn", + "example.gov", + "example.gov.cn", + "example.arpa", + "www.example.arpa", + "isexample.arpa", + "example.arpa.cn", + "www.example.arpa.cn", + "isexample.arpa.cn", + "sagernet.org", + "www.sagernet.org", + } + notMatchDomain := []string{ + "example.org.cn", + "notexample.org", + "example.com.cn", + "www.example.com.cn", + "example.net.cn", + "notexample.edu", + "notexample.edu.cn", + "www.example.gov", + "notexample.gov", + "sagernet.example.org", + "sing-box.sagernet.org", + } + for _, domain := range matchDomain { + require.True(t, rule.Match(&adapter.InboundContext{ + Domain: domain, + }), domain) + } + for _, domain := range notMatchDomain { + require.False(t, rule.Match(&adapter.InboundContext{ + Domain: domain, + }), domain) + } + ruleFromOptions, err := FromOptions(rules) + require.NoError(t, err) + require.Equal(t, ruleString, string(ruleFromOptions)) +} + +func TestHosts(t *testing.T) { + t.Parallel() + rules, err := ToOptions(strings.NewReader(` +127.0.0.1 localhost +::1 localhost #[IPv6] +0.0.0.0 google.com +`), logger.NOP()) + require.NoError(t, err) + require.Len(t, rules, 1) + rule, err := rule.NewHeadlessRule(context.Background(), rules[0]) + require.NoError(t, err) + matchDomain := []string{ + "google.com", + } + notMatchDomain := []string{ + "www.google.com", + "notgoogle.com", + "localhost", + } + for _, domain := range matchDomain { + require.True(t, rule.Match(&adapter.InboundContext{ + Domain: domain, + }), domain) + } + for _, domain := range notMatchDomain { + require.False(t, rule.Match(&adapter.InboundContext{ + Domain: domain, + }), domain) + } +} + +func TestSimpleHosts(t *testing.T) { + t.Parallel() + rules, err := ToOptions(strings.NewReader(` +example.com +www.example.org +`), logger.NOP()) + require.NoError(t, err) + require.Len(t, rules, 1) + rule, err := rule.NewHeadlessRule(context.Background(), rules[0]) + require.NoError(t, err) + matchDomain := []string{ + "example.com", + "www.example.org", + } + notMatchDomain := []string{ + "example.com.cn", + "www.example.com", + "notexample.com", + "example.org", + } + for _, domain := range matchDomain { + require.True(t, rule.Match(&adapter.InboundContext{ + Domain: domain, + }), domain) + } + for _, domain := range notMatchDomain { + require.False(t, rule.Match(&adapter.InboundContext{ + Domain: domain, + }), domain) + } +} diff --git a/common/dialer/default.go b/common/dialer/default.go new file mode 100644 index 00000000..4ffe00c1 --- /dev/null +++ b/common/dialer/default.go @@ -0,0 +1,390 @@ +package dialer + +import ( + "context" + "errors" + "net" + "net/netip" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "github.com/database64128/tfo-go/v2" +) + +var ( + _ ParallelInterfaceDialer = (*DefaultDialer)(nil) + _ WireGuardListener = (*DefaultDialer)(nil) +) + +type DefaultDialer struct { + dialer4 tfo.Dialer + dialer6 tfo.Dialer + udpDialer4 net.Dialer + udpDialer6 net.Dialer + udpListener net.ListenConfig + udpAddr4 string + udpAddr6 string + netns string + connectionManager adapter.ConnectionManager + networkManager adapter.NetworkManager + networkStrategy *C.NetworkStrategy + defaultNetworkStrategy bool + networkType []C.InterfaceType + fallbackNetworkType []C.InterfaceType + networkFallbackDelay time.Duration + networkLastFallback common.TypedValue[time.Time] +} + +func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDialer, error) { + connectionManager := service.FromContext[adapter.ConnectionManager](ctx) + networkManager := service.FromContext[adapter.NetworkManager](ctx) + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + + var ( + dialer net.Dialer + listener net.ListenConfig + interfaceFinder control.InterfaceFinder + networkStrategy *C.NetworkStrategy + defaultNetworkStrategy bool + networkType []C.InterfaceType + fallbackNetworkType []C.InterfaceType + networkFallbackDelay time.Duration + ) + if networkManager != nil { + interfaceFinder = networkManager.InterfaceFinder() + } else { + interfaceFinder = control.NewDefaultInterfaceFinder() + } + if options.BindInterface != "" { + if !(C.IsLinux || C.IsDarwin || C.IsWindows) { + return nil, E.New("`bind_interface` is only supported on Linux, macOS and Windows") + } + bindFunc := control.BindToInterface(interfaceFinder, options.BindInterface, -1) + dialer.Control = control.Append(dialer.Control, bindFunc) + listener.Control = control.Append(listener.Control, bindFunc) + } + if options.RoutingMark > 0 { + if !C.IsLinux { + return nil, E.New("`routing_mark` is only supported on Linux") + } + dialer.Control = control.Append(dialer.Control, setMarkWrapper(networkManager, uint32(options.RoutingMark), false)) + listener.Control = control.Append(listener.Control, setMarkWrapper(networkManager, uint32(options.RoutingMark), false)) + } + disableDefaultBind := options.BindInterface != "" || options.Inet4BindAddress != nil || options.Inet6BindAddress != nil + if disableDefaultBind || options.TCPFastOpen { + if options.NetworkStrategy != nil || len(options.NetworkType) > 0 && options.FallbackNetworkType == nil && options.FallbackDelay == 0 { + return nil, E.New("`network_strategy` is conflict with `bind_interface`, `inet4_bind_address`, `inet6_bind_address` and `tcp_fast_open`") + } + } + + if networkManager != nil { + defaultOptions := networkManager.DefaultOptions() + if defaultOptions.BindInterface != "" && !disableDefaultBind { + bindFunc := control.BindToInterface(networkManager.InterfaceFinder(), defaultOptions.BindInterface, -1) + dialer.Control = control.Append(dialer.Control, bindFunc) + listener.Control = control.Append(listener.Control, bindFunc) + } else if networkManager.AutoDetectInterface() && !disableDefaultBind { + if platformInterface != nil { + networkStrategy = (*C.NetworkStrategy)(options.NetworkStrategy) + networkType = common.Map(options.NetworkType, option.InterfaceType.Build) + fallbackNetworkType = common.Map(options.FallbackNetworkType, option.InterfaceType.Build) + if networkStrategy == nil && len(networkType) == 0 && len(fallbackNetworkType) == 0 { + networkStrategy = defaultOptions.NetworkStrategy + networkType = defaultOptions.NetworkType + fallbackNetworkType = defaultOptions.FallbackNetworkType + } + networkFallbackDelay = time.Duration(options.FallbackDelay) + if networkFallbackDelay == 0 && defaultOptions.FallbackDelay != 0 { + networkFallbackDelay = defaultOptions.FallbackDelay + } + if networkStrategy == nil { + networkStrategy = common.Ptr(C.NetworkStrategyDefault) + defaultNetworkStrategy = true + } + bindFunc := networkManager.ProtectFunc() + dialer.Control = control.Append(dialer.Control, bindFunc) + listener.Control = control.Append(listener.Control, bindFunc) + } else { + bindFunc := networkManager.AutoDetectInterfaceFunc() + dialer.Control = control.Append(dialer.Control, bindFunc) + listener.Control = control.Append(listener.Control, bindFunc) + } + } + if options.RoutingMark == 0 && defaultOptions.RoutingMark != 0 { + dialer.Control = control.Append(dialer.Control, setMarkWrapper(networkManager, defaultOptions.RoutingMark, true)) + listener.Control = control.Append(listener.Control, setMarkWrapper(networkManager, defaultOptions.RoutingMark, true)) + } + } + if networkManager != nil { + markFunc := networkManager.AutoRedirectOutputMarkFunc() + dialer.Control = control.Append(dialer.Control, markFunc) + listener.Control = control.Append(listener.Control, markFunc) + } + if options.ReuseAddr { + listener.Control = control.Append(listener.Control, control.ReuseAddr()) + } + if options.ProtectPath != "" { + dialer.Control = control.Append(dialer.Control, control.ProtectPath(options.ProtectPath)) + listener.Control = control.Append(listener.Control, control.ProtectPath(options.ProtectPath)) + } + if options.BindAddressNoPort { + if !C.IsLinux { + return nil, E.New("`bind_address_no_port` is only supported on Linux") + } + dialer.Control = control.Append(dialer.Control, control.BindAddressNoPort()) + } + if options.ConnectTimeout != 0 { + dialer.Timeout = time.Duration(options.ConnectTimeout) + } else { + dialer.Timeout = C.TCPConnectTimeout + } + if options.DisableTCPKeepAlive { + dialer.KeepAlive = -1 + dialer.KeepAliveConfig.Enable = false + } else { + keepIdle := time.Duration(options.TCPKeepAlive) + if keepIdle == 0 { + keepIdle = C.TCPKeepAliveInitial + } + keepInterval := time.Duration(options.TCPKeepAliveInterval) + if keepInterval == 0 { + keepInterval = C.TCPKeepAliveInterval + } + dialer.KeepAliveConfig = net.KeepAliveConfig{ + Enable: true, + Idle: keepIdle, + Interval: keepInterval, + } + } + var udpFragment bool + if options.UDPFragment != nil { + udpFragment = *options.UDPFragment + } else { + udpFragment = options.UDPFragmentDefault + } + if !udpFragment { + dialer.Control = control.Append(dialer.Control, control.DisableUDPFragment()) + listener.Control = control.Append(listener.Control, control.DisableUDPFragment()) + } + var ( + dialer4 = dialer + udpDialer4 = dialer + udpAddr4 string + ) + if options.Inet4BindAddress != nil { + bindAddr := options.Inet4BindAddress.Build(netip.IPv4Unspecified()) + dialer4.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} + udpDialer4.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} + udpAddr4 = M.SocksaddrFrom(bindAddr, 0).String() + } + var ( + dialer6 = dialer + udpDialer6 = dialer + udpAddr6 string + ) + if options.Inet6BindAddress != nil { + bindAddr := options.Inet6BindAddress.Build(netip.IPv6Unspecified()) + dialer6.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} + udpDialer6.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} + udpAddr6 = M.SocksaddrFrom(bindAddr, 0).String() + } + if options.TCPMultiPath { + dialer4.SetMultipathTCP(true) + } + tcpDialer4 := tfo.Dialer{Dialer: dialer4, DisableTFO: !options.TCPFastOpen} + tcpDialer6 := tfo.Dialer{Dialer: dialer6, DisableTFO: !options.TCPFastOpen} + return &DefaultDialer{ + dialer4: tcpDialer4, + dialer6: tcpDialer6, + udpDialer4: udpDialer4, + udpDialer6: udpDialer6, + udpListener: listener, + udpAddr4: udpAddr4, + udpAddr6: udpAddr6, + netns: options.NetNs, + connectionManager: connectionManager, + networkManager: networkManager, + networkStrategy: networkStrategy, + defaultNetworkStrategy: defaultNetworkStrategy, + networkType: networkType, + fallbackNetworkType: fallbackNetworkType, + networkFallbackDelay: networkFallbackDelay, + }, nil +} + +func setMarkWrapper(networkManager adapter.NetworkManager, mark uint32, isDefault bool) control.Func { + if networkManager == nil { + return control.RoutingMark(mark) + } + return func(network, address string, conn syscall.RawConn) error { + if networkManager.AutoRedirectOutputMark() != 0 { + if isDefault { + return E.New("`route.default_mark` is conflict with `tun.auto_redirect`") + } else { + return E.New("`routing_mark` is conflict with `tun.auto_redirect`") + } + } + return control.RoutingMark(mark)(network, address, conn) + } +} + +func (d *DefaultDialer) DialContext(ctx context.Context, network string, address M.Socksaddr) (net.Conn, error) { + if !address.IsValid() { + return nil, E.New("invalid address") + } else if address.IsDomain() { + return nil, E.New("domain not resolved") + } + if d.networkStrategy == nil { + return d.trackConn(listener.ListenNetworkNamespace[net.Conn](d.netns, func() (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkUDP: + if !address.IsIPv6() { + return d.udpDialer4.DialContext(ctx, network, address.String()) + } else { + return d.udpDialer6.DialContext(ctx, network, address.String()) + } + } + if !address.IsIPv6() { + return DialSlowContext(&d.dialer4, ctx, network, address) + } else { + return DialSlowContext(&d.dialer6, ctx, network, address) + } + })) + } else { + return d.DialParallelInterface(ctx, network, address, d.networkStrategy, d.networkType, d.fallbackNetworkType, d.networkFallbackDelay) + } +} + +func (d *DefaultDialer) DialParallelInterface(ctx context.Context, network string, address M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { + if strategy == nil { + strategy = d.networkStrategy + } + if strategy == nil { + return d.DialContext(ctx, network, address) + } + if len(interfaceType) == 0 { + interfaceType = d.networkType + } + if len(fallbackInterfaceType) == 0 { + fallbackInterfaceType = d.fallbackNetworkType + } + if fallbackDelay == 0 { + fallbackDelay = d.networkFallbackDelay + } + var dialer net.Dialer + if N.NetworkName(network) == N.NetworkTCP { + dialer = d.dialer4.Dialer + } else { + dialer = d.udpDialer4 + } + fastFallback := time.Since(d.networkLastFallback.Load()) < C.TCPTimeout + var ( + conn net.Conn + isPrimary bool + err error + ) + if !fastFallback { + conn, isPrimary, err = d.dialParallelInterface(ctx, dialer, network, address.String(), *strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + } else { + conn, isPrimary, err = d.dialParallelInterfaceFastFallback(ctx, dialer, network, address.String(), *strategy, interfaceType, fallbackInterfaceType, fallbackDelay, d.networkLastFallback.Store) + } + if err != nil { + // bind interface failed on legacy xiaomi systems + if d.defaultNetworkStrategy && errors.Is(err, syscall.EPERM) { + d.networkStrategy = nil + return d.DialContext(ctx, network, address) + } else { + return nil, err + } + } + if !fastFallback && !isPrimary { + d.networkLastFallback.Store(time.Now()) + } + return d.trackConn(conn, nil) +} + +func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if d.networkStrategy == nil { + return d.trackPacketConn(listener.ListenNetworkNamespace[net.PacketConn](d.netns, func() (net.PacketConn, error) { + if destination.IsIPv6() { + return d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr6) + } else if destination.IsIPv4() && !destination.Addr.IsUnspecified() { + return d.udpListener.ListenPacket(ctx, N.NetworkUDP+"4", d.udpAddr4) + } else { + return d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr4) + } + })) + } else { + return d.ListenSerialInterfacePacket(ctx, destination, d.networkStrategy, d.networkType, d.fallbackNetworkType, d.networkFallbackDelay) + } +} + +func (d *DefaultDialer) DialerForICMPDestination(destination netip.Addr) net.Dialer { + if !destination.Is6() { + return d.dialer4.Dialer + } else { + return d.dialer6.Dialer + } +} + +func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, error) { + if strategy == nil { + strategy = d.networkStrategy + } + if strategy == nil { + return d.ListenPacket(ctx, destination) + } + if len(interfaceType) == 0 { + interfaceType = d.networkType + } + if len(fallbackInterfaceType) == 0 { + fallbackInterfaceType = d.fallbackNetworkType + } + if fallbackDelay == 0 { + fallbackDelay = d.networkFallbackDelay + } + network := N.NetworkUDP + if destination.IsIPv4() && !destination.Addr.IsUnspecified() { + network += "4" + } + packetConn, err := d.listenSerialInterfacePacket(ctx, d.udpListener, network, "", *strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + if err != nil { + // bind interface failed on legacy xiaomi systems + if d.defaultNetworkStrategy && errors.Is(err, syscall.EPERM) { + d.networkStrategy = nil + return d.ListenPacket(ctx, destination) + } else { + return nil, err + } + } + return d.trackPacketConn(packetConn, nil) +} + +func (d *DefaultDialer) WireGuardControl() control.Func { + return d.udpListener.Control +} + +func (d *DefaultDialer) trackConn(conn net.Conn, err error) (net.Conn, error) { + if d.connectionManager == nil || err != nil { + return conn, err + } + return d.connectionManager.TrackConn(conn), nil +} + +func (d *DefaultDialer) trackPacketConn(conn net.PacketConn, err error) (net.PacketConn, error) { + if d.connectionManager == nil || err != nil { + return conn, err + } + return d.connectionManager.TrackPacketConn(conn), nil +} diff --git a/common/dialer/default_parallel_interface.go b/common/dialer/default_parallel_interface.go new file mode 100644 index 00000000..ca374b2e --- /dev/null +++ b/common/dialer/default_parallel_interface.go @@ -0,0 +1,244 @@ +package dialer + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" +) + +func (d *DefaultDialer) dialParallelInterface(ctx context.Context, dialer net.Dialer, network string, addr string, strategy C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, bool, error) { + primaryInterfaces, fallbackInterfaces := selectInterfaces(d.networkManager, strategy, interfaceType, fallbackInterfaceType) + if len(primaryInterfaces)+len(fallbackInterfaces) == 0 { + return nil, false, E.New("no available network interface") + } + defaultInterface := d.networkManager.InterfaceMonitor().DefaultInterface() + if fallbackDelay == 0 { + fallbackDelay = N.DefaultFallbackDelay + } + returned := make(chan struct{}) + defer close(returned) + type dialResult struct { + net.Conn + error + primary bool + } + results := make(chan dialResult) // unbuffered + startRacer := func(ctx context.Context, primary bool, iif adapter.NetworkInterface) { + perNetDialer := dialer + if defaultInterface == nil || iif.Index != defaultInterface.Index { + perNetDialer.Control = control.Append(perNetDialer.Control, control.BindToInterface(nil, iif.Name, iif.Index)) + } + conn, err := perNetDialer.DialContext(ctx, network, addr) + if err != nil { + select { + case results <- dialResult{error: E.Cause(err, "dial ", iif.Name, " (", iif.Index, ")"), primary: primary}: + case <-returned: + } + } else { + select { + case results <- dialResult{Conn: conn, primary: primary}: + case <-returned: + conn.Close() + } + } + } + primaryCtx, primaryCancel := context.WithCancel(ctx) + defer primaryCancel() + for _, iif := range primaryInterfaces { + go startRacer(primaryCtx, true, iif) + } + var ( + fallbackTimer *time.Timer + fallbackChan <-chan time.Time + ) + if len(fallbackInterfaces) > 0 { + fallbackTimer = time.NewTimer(fallbackDelay) + defer fallbackTimer.Stop() + fallbackChan = fallbackTimer.C + } + var errors []error + for { + select { + case <-fallbackChan: + fallbackCtx, fallbackCancel := context.WithCancel(ctx) + defer fallbackCancel() + for _, iif := range fallbackInterfaces { + go startRacer(fallbackCtx, false, iif) + } + case res := <-results: + if res.error == nil { + return res.Conn, res.primary, nil + } + errors = append(errors, res.error) + if len(errors) == len(primaryInterfaces)+len(fallbackInterfaces) { + return nil, false, E.Errors(errors...) + } + if res.primary && fallbackTimer != nil && fallbackTimer.Stop() { + fallbackTimer.Reset(0) + } + } + } +} + +func (d *DefaultDialer) dialParallelInterfaceFastFallback(ctx context.Context, dialer net.Dialer, network string, addr string, strategy C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration, resetFastFallback func(time.Time)) (net.Conn, bool, error) { + primaryInterfaces, fallbackInterfaces := selectInterfaces(d.networkManager, strategy, interfaceType, fallbackInterfaceType) + if len(primaryInterfaces)+len(fallbackInterfaces) == 0 { + return nil, false, E.New("no available network interface") + } + defaultInterface := d.networkManager.InterfaceMonitor().DefaultInterface() + if fallbackDelay == 0 { + fallbackDelay = N.DefaultFallbackDelay + } + returned := make(chan struct{}) + defer close(returned) + type dialResult struct { + net.Conn + error + primary bool + } + startAt := time.Now() + results := make(chan dialResult) // unbuffered + startRacer := func(ctx context.Context, primary bool, iif adapter.NetworkInterface) { + perNetDialer := dialer + if defaultInterface == nil || iif.Index != defaultInterface.Index { + perNetDialer.Control = control.Append(perNetDialer.Control, control.BindToInterface(nil, iif.Name, iif.Index)) + } + conn, err := perNetDialer.DialContext(ctx, network, addr) + if err != nil { + select { + case results <- dialResult{error: E.Cause(err, "dial ", iif.Name, " (", iif.Index, ")"), primary: primary}: + case <-returned: + } + } else { + select { + case results <- dialResult{Conn: conn, primary: primary}: + case <-returned: + if primary && time.Since(startAt) <= fallbackDelay { + resetFastFallback(time.Time{}) + } + conn.Close() + } + } + } + for _, iif := range primaryInterfaces { + go startRacer(ctx, true, iif) + } + fallbackCtx, fallbackCancel := context.WithCancel(ctx) + defer fallbackCancel() + for _, iif := range fallbackInterfaces { + go startRacer(fallbackCtx, false, iif) + } + var errors []error + for { + select { + case res := <-results: + if res.error == nil { + return res.Conn, res.primary, nil + } + errors = append(errors, res.error) + if len(errors) == len(primaryInterfaces)+len(fallbackInterfaces) { + return nil, false, E.Errors(errors...) + } + } + } +} + +func (d *DefaultDialer) listenSerialInterfacePacket(ctx context.Context, listener net.ListenConfig, network string, addr string, strategy C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, error) { + primaryInterfaces, fallbackInterfaces := selectInterfaces(d.networkManager, strategy, interfaceType, fallbackInterfaceType) + if len(primaryInterfaces)+len(fallbackInterfaces) == 0 { + return nil, E.New("no available network interface") + } + defaultInterface := d.networkManager.InterfaceMonitor().DefaultInterface() + var errors []error + for _, primaryInterface := range primaryInterfaces { + perNetListener := listener + if defaultInterface == nil || primaryInterface.Index != defaultInterface.Index { + perNetListener.Control = control.Append(perNetListener.Control, control.BindToInterface(nil, primaryInterface.Name, primaryInterface.Index)) + } + conn, err := perNetListener.ListenPacket(ctx, network, addr) + if err == nil { + return conn, nil + } + errors = append(errors, E.Cause(err, "listen ", primaryInterface.Name, " (", primaryInterface.Index, ")")) + } + for _, fallbackInterface := range fallbackInterfaces { + perNetListener := listener + if defaultInterface == nil || fallbackInterface.Index != defaultInterface.Index { + perNetListener.Control = control.Append(perNetListener.Control, control.BindToInterface(nil, fallbackInterface.Name, fallbackInterface.Index)) + } + conn, err := perNetListener.ListenPacket(ctx, network, addr) + if err == nil { + return conn, nil + } + errors = append(errors, E.Cause(err, "listen ", fallbackInterface.Name, " (", fallbackInterface.Index, ")")) + } + return nil, E.Errors(errors...) +} + +func selectInterfaces(networkManager adapter.NetworkManager, strategy C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType) (primaryInterfaces []adapter.NetworkInterface, fallbackInterfaces []adapter.NetworkInterface) { + interfaces := networkManager.NetworkInterfaces() + switch strategy { + case C.NetworkStrategyDefault: + if len(interfaceType) == 0 { + defaultIf := networkManager.InterfaceMonitor().DefaultInterface() + if defaultIf != nil { + for _, iif := range interfaces { + if iif.Index == defaultIf.Index { + primaryInterfaces = append(primaryInterfaces, iif) + } + } + } else { + primaryInterfaces = interfaces + } + } else { + primaryInterfaces = common.Filter(interfaces, func(it adapter.NetworkInterface) bool { + return common.Contains(interfaceType, it.Type) + }) + } + case C.NetworkStrategyHybrid: + if len(interfaceType) == 0 { + primaryInterfaces = interfaces + } else { + primaryInterfaces = common.Filter(interfaces, func(it adapter.NetworkInterface) bool { + return common.Contains(interfaceType, it.Type) + }) + } + case C.NetworkStrategyFallback: + if len(interfaceType) == 0 { + defaultIf := networkManager.InterfaceMonitor().DefaultInterface() + if defaultIf != nil { + for _, iif := range interfaces { + if iif.Index == defaultIf.Index { + primaryInterfaces = append(primaryInterfaces, iif) + break + } + } + } else { + primaryInterfaces = interfaces + } + } else { + primaryInterfaces = common.Filter(interfaces, func(it adapter.NetworkInterface) bool { + return common.Contains(interfaceType, it.Type) + }) + } + if len(fallbackInterfaceType) == 0 { + fallbackInterfaces = common.Filter(interfaces, func(it adapter.NetworkInterface) bool { + return !common.Any(primaryInterfaces, func(iif adapter.NetworkInterface) bool { + return it.Index == iif.Index + }) + }) + } else { + fallbackInterfaces = common.Filter(interfaces, func(iif adapter.NetworkInterface) bool { + return common.Contains(fallbackInterfaceType, iif.Type) + }) + } + } + return primaryInterfaces, fallbackInterfaces +} diff --git a/common/dialer/default_parallel_network.go b/common/dialer/default_parallel_network.go new file mode 100644 index 00000000..006e5747 --- /dev/null +++ b/common/dialer/default_parallel_network.go @@ -0,0 +1,161 @@ +package dialer + +import ( + "context" + "net" + "net/netip" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func DialSerialNetwork(ctx context.Context, dialer N.Dialer, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { + if len(destinationAddresses) == 0 { + if !destination.IsIP() { + panic("invalid usage") + } + destinationAddresses = []netip.Addr{destination.Addr} + } + if parallelDialer, isParallel := dialer.(ParallelNetworkDialer); isParallel { + return parallelDialer.DialParallelNetwork(ctx, network, destination, destinationAddresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + } + var errors []error + if parallelDialer, isParallel := dialer.(ParallelInterfaceDialer); isParallel { + for _, address := range destinationAddresses { + conn, err := parallelDialer.DialParallelInterface(ctx, network, M.SocksaddrFrom(address, destination.Port), strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + if err == nil { + return conn, nil + } + errors = append(errors, err) + } + } else { + for _, address := range destinationAddresses { + conn, err := dialer.DialContext(ctx, network, M.SocksaddrFrom(address, destination.Port)) + if err == nil { + return conn, nil + } + errors = append(errors, err) + } + } + return nil, E.Errors(errors...) +} + +func DialParallelNetwork(ctx context.Context, dialer ParallelInterfaceDialer, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, preferIPv6 bool, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { + if len(destinationAddresses) == 0 { + if !destination.IsIP() { + panic("invalid usage") + } + destinationAddresses = []netip.Addr{destination.Addr} + } + + if fallbackDelay == 0 { + fallbackDelay = N.DefaultFallbackDelay + } + + returned := make(chan struct{}) + defer close(returned) + + addresses4 := common.Filter(destinationAddresses, func(address netip.Addr) bool { + return address.Is4() || address.Is4In6() + }) + addresses6 := common.Filter(destinationAddresses, func(address netip.Addr) bool { + return address.Is6() && !address.Is4In6() + }) + if len(addresses4) == 0 || len(addresses6) == 0 { + return DialSerialNetwork(ctx, dialer, network, destination, destinationAddresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + } + var primaries, fallbacks []netip.Addr + if preferIPv6 { + primaries = addresses6 + fallbacks = addresses4 + } else { + primaries = addresses4 + fallbacks = addresses6 + } + type dialResult struct { + net.Conn + error + primary bool + done bool + } + results := make(chan dialResult) // unbuffered + startRacer := func(ctx context.Context, primary bool) { + ras := primaries + if !primary { + ras = fallbacks + } + c, err := DialSerialNetwork(ctx, dialer, network, destination, ras, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + select { + case results <- dialResult{Conn: c, error: err, primary: primary, done: true}: + case <-returned: + if c != nil { + c.Close() + } + } + } + var primary, fallback dialResult + primaryCtx, primaryCancel := context.WithCancel(ctx) + defer primaryCancel() + go startRacer(primaryCtx, true) + fallbackTimer := time.NewTimer(fallbackDelay) + defer fallbackTimer.Stop() + for { + select { + case <-fallbackTimer.C: + fallbackCtx, fallbackCancel := context.WithCancel(ctx) + defer fallbackCancel() + go startRacer(fallbackCtx, false) + + case res := <-results: + if res.error == nil { + return res.Conn, nil + } + if res.primary { + primary = res + } else { + fallback = res + } + if primary.done && fallback.done { + return nil, primary.error + } + if res.primary && fallbackTimer.Stop() { + fallbackTimer.Reset(0) + } + } + } +} + +func ListenSerialNetworkPacket(ctx context.Context, dialer N.Dialer, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) { + if len(destinationAddresses) == 0 { + if !destination.IsIP() { + panic("invalid usage") + } + destinationAddresses = []netip.Addr{destination.Addr} + } + if parallelDialer, isParallel := dialer.(ParallelNetworkDialer); isParallel { + return parallelDialer.ListenSerialNetworkPacket(ctx, destination, destinationAddresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + } + var errors []error + if parallelDialer, isParallel := dialer.(ParallelInterfaceDialer); isParallel { + for _, address := range destinationAddresses { + conn, err := parallelDialer.ListenSerialInterfacePacket(ctx, M.SocksaddrFrom(address, destination.Port), strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + if err == nil { + return conn, address, nil + } + errors = append(errors, err) + } + } else { + for _, address := range destinationAddresses { + conn, err := dialer.ListenPacket(ctx, M.SocksaddrFrom(address, destination.Port)) + if err == nil { + return conn, address, nil + } + errors = append(errors, err) + } + } + return nil, netip.Addr{}, E.Errors(errors...) +} diff --git a/common/dialer/detour.go b/common/dialer/detour.go new file mode 100644 index 00000000..5c0b552b --- /dev/null +++ b/common/dialer/detour.go @@ -0,0 +1,85 @@ +package dialer + +import ( + "context" + "net" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type DirectDialer interface { + IsEmpty() bool +} + +type DetourDialer struct { + outboundManager adapter.OutboundManager + detour string + legacyDNSDialer bool + dialer N.Dialer + initOnce sync.Once + initErr error +} + +func NewDetour(outboundManager adapter.OutboundManager, detour string, legacyDNSDialer bool) N.Dialer { + return &DetourDialer{ + outboundManager: outboundManager, + detour: detour, + legacyDNSDialer: legacyDNSDialer, + } +} + +func InitializeDetour(dialer N.Dialer) error { + detourDialer, isDetour := common.Cast[*DetourDialer](dialer) + if !isDetour { + return nil + } + return common.Error(detourDialer.Dialer()) +} + +func (d *DetourDialer) Dialer() (N.Dialer, error) { + d.initOnce.Do(d.init) + return d.dialer, d.initErr +} + +func (d *DetourDialer) init() { + dialer, loaded := d.outboundManager.Outbound(d.detour) + if !loaded { + d.initErr = E.New("outbound detour not found: ", d.detour) + return + } + if !d.legacyDNSDialer { + if directDialer, isDirect := dialer.(DirectDialer); isDirect { + if directDialer.IsEmpty() { + d.initErr = E.New("detour to an empty direct outbound makes no sense") + return + } + } + } + d.dialer = dialer +} + +func (d *DetourDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + dialer, err := d.Dialer() + if err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, destination) +} + +func (d *DetourDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + dialer, err := d.Dialer() + if err != nil { + return nil, err + } + return dialer.ListenPacket(ctx, destination) +} + +func (d *DetourDialer) Upstream() any { + detour, _ := d.Dialer() + return detour +} diff --git a/common/dialer/dialer.go b/common/dialer/dialer.go new file mode 100644 index 00000000..ca6f905f --- /dev/null +++ b/common/dialer/dialer.go @@ -0,0 +1,152 @@ +package dialer + +import ( + "context" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +type Options struct { + Context context.Context + Options option.DialerOptions + RemoteIsDomain bool + DirectResolver bool + ResolverOnDetour bool + NewDialer bool + LegacyDNSDialer bool + DirectOutbound bool +} + +// TODO: merge with NewWithOptions +func New(ctx context.Context, options option.DialerOptions, remoteIsDomain bool) (N.Dialer, error) { + return NewWithOptions(Options{ + Context: ctx, + Options: options, + RemoteIsDomain: remoteIsDomain, + }) +} + +func NewWithOptions(options Options) (N.Dialer, error) { + dialOptions := options.Options + var ( + dialer N.Dialer + err error + ) + if dialOptions.Detour != "" { + outboundManager := service.FromContext[adapter.OutboundManager](options.Context) + if outboundManager == nil { + return nil, E.New("missing outbound manager") + } + dialer = NewDetour(outboundManager, dialOptions.Detour, options.LegacyDNSDialer) + } else { + dialer, err = NewDefault(options.Context, dialOptions) + if err != nil { + return nil, err + } + } + if options.RemoteIsDomain && (dialOptions.Detour == "" || options.ResolverOnDetour || dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "") { + networkManager := service.FromContext[adapter.NetworkManager](options.Context) + dnsTransport := service.FromContext[adapter.DNSTransportManager](options.Context) + var defaultOptions adapter.NetworkOptions + if networkManager != nil { + defaultOptions = networkManager.DefaultOptions() + } + var ( + server string + dnsQueryOptions adapter.DNSQueryOptions + resolveFallbackDelay time.Duration + ) + if dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "" { + var transport adapter.DNSTransport + if !options.DirectResolver { + var loaded bool + transport, loaded = dnsTransport.Transport(dialOptions.DomainResolver.Server) + if !loaded { + return nil, E.New("domain resolver not found: " + dialOptions.DomainResolver.Server) + } + } + var strategy C.DomainStrategy + if dialOptions.DomainResolver.Strategy != option.DomainStrategy(C.DomainStrategyAsIS) { + strategy = C.DomainStrategy(dialOptions.DomainResolver.Strategy) + } else if + //nolint:staticcheck + dialOptions.DomainStrategy != option.DomainStrategy(C.DomainStrategyAsIS) { + //nolint:staticcheck + strategy = C.DomainStrategy(dialOptions.DomainStrategy) + deprecated.Report(options.Context, deprecated.OptionLegacyDomainStrategyOptions) + } + server = dialOptions.DomainResolver.Server + dnsQueryOptions = adapter.DNSQueryOptions{ + Transport: transport, + Strategy: strategy, + DisableCache: dialOptions.DomainResolver.DisableCache, + DisableOptimisticCache: dialOptions.DomainResolver.DisableOptimisticCache, + RewriteTTL: dialOptions.DomainResolver.RewriteTTL, + ClientSubnet: dialOptions.DomainResolver.ClientSubnet.Build(netip.Prefix{}), + } + resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay) + } else if options.DirectResolver { + return nil, E.New("missing domain resolver for domain server address") + } else { + if defaultOptions.DomainResolver != "" { + dnsQueryOptions = defaultOptions.DomainResolveOptions + transport, loaded := dnsTransport.Transport(defaultOptions.DomainResolver) + if !loaded { + return nil, E.New("default domain resolver not found: " + defaultOptions.DomainResolver) + } + dnsQueryOptions.Transport = transport + resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay) + } else { + transports := dnsTransport.Transports() + if len(transports) < 2 { + dnsQueryOptions.Transport = dnsTransport.Default() + } else if options.NewDialer { + return nil, E.New("missing domain resolver for domain server address") + } else { + deprecated.Report(options.Context, deprecated.OptionMissingDomainResolver) + } + } + if + //nolint:staticcheck + dialOptions.DomainStrategy != option.DomainStrategy(C.DomainStrategyAsIS) { + //nolint:staticcheck + dnsQueryOptions.Strategy = C.DomainStrategy(dialOptions.DomainStrategy) + deprecated.Report(options.Context, deprecated.OptionLegacyDomainStrategyOptions) + } + } + dialer = NewResolveDialer( + options.Context, + dialer, + dialOptions.Detour == "" && !dialOptions.TCPFastOpen, + server, + dnsQueryOptions, + resolveFallbackDelay, + ) + } + return dialer, nil +} + +type ParallelInterfaceDialer interface { + N.Dialer + DialParallelInterface(ctx context.Context, network string, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) + ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, error) +} + +type ParallelNetworkDialer interface { + DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) + ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) +} + +type PacketDialerWithDestination interface { + ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) +} diff --git a/common/dialer/resolve.go b/common/dialer/resolve.go new file mode 100644 index 00000000..21fe38d5 --- /dev/null +++ b/common/dialer/resolve.go @@ -0,0 +1,194 @@ +package dialer + +import ( + "context" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var ( + _ N.Dialer = (*resolveDialer)(nil) + _ ParallelInterfaceDialer = (*resolveParallelNetworkDialer)(nil) +) + +type ResolveDialer interface { + N.Dialer + QueryOptions() adapter.DNSQueryOptions +} + +type ParallelInterfaceResolveDialer interface { + ParallelInterfaceDialer + QueryOptions() adapter.DNSQueryOptions +} + +type resolveDialer struct { + transport adapter.DNSTransportManager + router adapter.DNSRouter + dialer N.Dialer + parallel bool + server string + initOnce sync.Once + initErr error + queryOptions adapter.DNSQueryOptions + fallbackDelay time.Duration +} + +func NewResolveDialer(ctx context.Context, dialer N.Dialer, parallel bool, server string, queryOptions adapter.DNSQueryOptions, fallbackDelay time.Duration) ResolveDialer { + if parallelDialer, isParallel := dialer.(ParallelInterfaceDialer); isParallel { + return &resolveParallelNetworkDialer{ + resolveDialer{ + transport: service.FromContext[adapter.DNSTransportManager](ctx), + router: service.FromContext[adapter.DNSRouter](ctx), + dialer: dialer, + parallel: parallel, + server: server, + queryOptions: queryOptions, + fallbackDelay: fallbackDelay, + }, + parallelDialer, + } + } + return &resolveDialer{ + transport: service.FromContext[adapter.DNSTransportManager](ctx), + router: service.FromContext[adapter.DNSRouter](ctx), + dialer: dialer, + parallel: parallel, + server: server, + queryOptions: queryOptions, + fallbackDelay: fallbackDelay, + } +} + +type resolveParallelNetworkDialer struct { + resolveDialer + dialer ParallelInterfaceDialer +} + +func (d *resolveDialer) initialize() error { + d.initOnce.Do(d.initServer) + return d.initErr +} + +func (d *resolveDialer) initServer() { + if d.server == "" { + return + } + transport, loaded := d.transport.Transport(d.server) + if !loaded { + d.initErr = E.New("domain resolver not found: " + d.server) + return + } + d.queryOptions.Transport = transport +} + +func (d *resolveDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + err := d.initialize() + if err != nil { + return nil, err + } + if !destination.IsDomain() { + return d.dialer.DialContext(ctx, network, destination) + } + ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) + addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions) + if err != nil { + return nil, err + } + if d.parallel { + return N.DialParallel(ctx, d.dialer, network, destination, addresses, d.queryOptions.Strategy == C.DomainStrategyPreferIPv6, d.fallbackDelay) + } else { + return N.DialSerial(ctx, d.dialer, network, destination, addresses) + } +} + +func (d *resolveDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + err := d.initialize() + if err != nil { + return nil, err + } + if !destination.IsDomain() { + return d.dialer.ListenPacket(ctx, destination) + } + ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) + addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions) + if err != nil { + return nil, err + } + conn, destinationAddress, err := N.ListenSerial(ctx, d.dialer, destination, addresses) + if err != nil { + return nil, err + } + return bufio.NewNATPacketConn(bufio.NewPacketConn(conn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil +} + +func (d *resolveDialer) QueryOptions() adapter.DNSQueryOptions { + return d.queryOptions +} + +func (d *resolveDialer) Upstream() any { + return d.dialer +} + +func (d *resolveParallelNetworkDialer) DialParallelInterface(ctx context.Context, network string, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { + err := d.initialize() + if err != nil { + return nil, err + } + if !destination.IsDomain() { + return d.dialer.DialContext(ctx, network, destination) + } + ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) + addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions) + if err != nil { + return nil, err + } + if fallbackDelay == 0 { + fallbackDelay = d.fallbackDelay + } + if d.parallel { + return DialParallelNetwork(ctx, d.dialer, network, destination, addresses, d.queryOptions.Strategy == C.DomainStrategyPreferIPv6, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + } else { + return DialSerialNetwork(ctx, d.dialer, network, destination, addresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + } +} + +func (d *resolveParallelNetworkDialer) ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, error) { + err := d.initialize() + if err != nil { + return nil, err + } + if !destination.IsDomain() { + return d.dialer.ListenPacket(ctx, destination) + } + ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) + addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions) + if err != nil { + return nil, err + } + if fallbackDelay == 0 { + fallbackDelay = d.fallbackDelay + } + conn, destinationAddress, err := ListenSerialNetworkPacket(ctx, d.dialer, destination, addresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay) + if err != nil { + return nil, err + } + return bufio.NewNATPacketConn(bufio.NewPacketConn(conn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil +} + +func (d *resolveParallelNetworkDialer) QueryOptions() adapter.DNSQueryOptions { + return d.queryOptions +} + +func (d *resolveParallelNetworkDialer) Upstream() any { + return d.dialer +} diff --git a/common/dialer/router.go b/common/dialer/router.go new file mode 100644 index 00000000..801a36b1 --- /dev/null +++ b/common/dialer/router.go @@ -0,0 +1,33 @@ +package dialer + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +type DefaultOutboundDialer struct { + outbound adapter.OutboundManager +} + +func NewDefaultOutbound(ctx context.Context) N.Dialer { + return &DefaultOutboundDialer{ + outbound: service.FromContext[adapter.OutboundManager](ctx), + } +} + +func (d *DefaultOutboundDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return d.outbound.Default().DialContext(ctx, network, destination) +} + +func (d *DefaultOutboundDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return d.outbound.Default().ListenPacket(ctx, destination) +} + +func (d *DefaultOutboundDialer) Upstream() any { + return d.outbound.Default() +} diff --git a/common/dialer/tfo.go b/common/dialer/tfo.go new file mode 100644 index 00000000..e8e93083 --- /dev/null +++ b/common/dialer/tfo.go @@ -0,0 +1,180 @@ +package dialer + +import ( + "context" + "io" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/database64128/tfo-go/v2" +) + +type slowOpenConn struct { + dialer *tfo.Dialer + ctx context.Context + network string + destination M.Socksaddr + conn atomic.Pointer[net.TCPConn] + create chan struct{} + done chan struct{} + access sync.Mutex + closeOnce sync.Once + err error +} + +func DialSlowContext(dialer *tfo.Dialer, ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if dialer.DisableTFO || N.NetworkName(network) != N.NetworkTCP { + switch N.NetworkName(network) { + case N.NetworkTCP, N.NetworkUDP: + return dialer.Dialer.DialContext(ctx, network, destination.String()) + default: + return dialer.Dialer.DialContext(ctx, network, destination.AddrString()) + } + } + return &slowOpenConn{ + dialer: dialer, + ctx: ctx, + network: network, + destination: destination, + create: make(chan struct{}), + done: make(chan struct{}), + }, nil +} + +func (c *slowOpenConn) Read(b []byte) (n int, err error) { + conn := c.conn.Load() + if conn != nil { + return conn.Read(b) + } + select { + case <-c.create: + if c.err != nil { + return 0, c.err + } + return c.conn.Load().Read(b) + case <-c.done: + return 0, os.ErrClosed + } +} + +func (c *slowOpenConn) Write(b []byte) (n int, err error) { + tcpConn := c.conn.Load() + if tcpConn != nil { + return tcpConn.Write(b) + } + c.access.Lock() + defer c.access.Unlock() + select { + case <-c.create: + if c.err != nil { + return 0, c.err + } + return c.conn.Load().Write(b) + case <-c.done: + return 0, os.ErrClosed + default: + } + conn, err := c.dialer.DialContext(c.ctx, c.network, c.destination.String(), b) + if err != nil { + c.err = err + } else { + c.conn.Store(conn.(*net.TCPConn)) + } + n = len(b) + close(c.create) + return +} + +func (c *slowOpenConn) Close() error { + c.closeOnce.Do(func() { + close(c.done) + conn := c.conn.Load() + if conn != nil { + conn.Close() + } + }) + return nil +} + +func (c *slowOpenConn) LocalAddr() net.Addr { + conn := c.conn.Load() + if conn == nil { + return M.Socksaddr{} + } + return conn.LocalAddr() +} + +func (c *slowOpenConn) RemoteAddr() net.Addr { + conn := c.conn.Load() + if conn == nil { + return M.Socksaddr{} + } + return conn.RemoteAddr() +} + +func (c *slowOpenConn) SetDeadline(t time.Time) error { + conn := c.conn.Load() + if conn == nil { + return os.ErrInvalid + } + return conn.SetDeadline(t) +} + +func (c *slowOpenConn) SetReadDeadline(t time.Time) error { + conn := c.conn.Load() + if conn == nil { + return os.ErrInvalid + } + return conn.SetReadDeadline(t) +} + +func (c *slowOpenConn) SetWriteDeadline(t time.Time) error { + conn := c.conn.Load() + if conn == nil { + return os.ErrInvalid + } + return conn.SetWriteDeadline(t) +} + +func (c *slowOpenConn) Upstream() any { + return common.PtrOrNil(c.conn.Load()) +} + +func (c *slowOpenConn) ReaderReplaceable() bool { + return c.conn.Load() != nil +} + +func (c *slowOpenConn) WriterReplaceable() bool { + return c.conn.Load() != nil +} + +func (c *slowOpenConn) LazyHeadroom() bool { + return c.conn.Load() == nil +} + +func (c *slowOpenConn) NeedHandshake() bool { + return c.conn.Load() == nil +} + +func (c *slowOpenConn) WriteTo(w io.Writer) (n int64, err error) { + conn := c.conn.Load() + if conn == nil { + select { + case <-c.create: + if c.err != nil { + return 0, c.err + } + case <-c.done: + return 0, c.err + } + } + return bufio.Copy(w, c.conn.Load()) +} diff --git a/common/dialer/wireguard.go b/common/dialer/wireguard.go new file mode 100644 index 00000000..8a916a59 --- /dev/null +++ b/common/dialer/wireguard.go @@ -0,0 +1,9 @@ +package dialer + +import ( + "github.com/sagernet/sing/common/control" +) + +type WireGuardListener interface { + WireGuardControl() control.Func +} diff --git a/common/geoip/reader.go b/common/geoip/reader.go new file mode 100644 index 00000000..9e225f75 --- /dev/null +++ b/common/geoip/reader.go @@ -0,0 +1,38 @@ +package geoip + +import ( + "net/netip" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/oschwald/maxminddb-golang" +) + +type Reader struct { + reader *maxminddb.Reader +} + +func Open(path string) (*Reader, []string, error) { + database, err := maxminddb.Open(path) + if err != nil { + return nil, nil, err + } + if database.Metadata.DatabaseType != "sing-geoip" { + database.Close() + return nil, nil, E.New("incorrect database type, expected sing-geoip, got ", database.Metadata.DatabaseType) + } + return &Reader{database}, database.Metadata.Languages, nil +} + +func (r *Reader) Lookup(addr netip.Addr) string { + var code string + _ = r.reader.Lookup(addr.AsSlice(), &code) + if code != "" { + return code + } + return "unknown" +} + +func (r *Reader) Close() error { + return r.reader.Close() +} diff --git a/common/geosite/compat_test.go b/common/geosite/compat_test.go new file mode 100644 index 00000000..1a55c644 --- /dev/null +++ b/common/geosite/compat_test.go @@ -0,0 +1,234 @@ +package geosite + +import ( + "bufio" + "bytes" + "encoding/binary" + "strings" + "testing" + + "github.com/sagernet/sing/common/varbin" + + "github.com/stretchr/testify/require" +) + +// Old implementation using varbin reflection-based serialization + +func oldWriteString(writer varbin.Writer, value string) error { + //nolint:staticcheck + return varbin.Write(writer, binary.BigEndian, value) +} + +func oldWriteItem(writer varbin.Writer, item Item) error { + //nolint:staticcheck + return varbin.Write(writer, binary.BigEndian, item) +} + +func oldReadString(reader varbin.Reader) (string, error) { + //nolint:staticcheck + return varbin.ReadValue[string](reader, binary.BigEndian) +} + +func oldReadItem(reader varbin.Reader) (Item, error) { + //nolint:staticcheck + return varbin.ReadValue[Item](reader, binary.BigEndian) +} + +func TestStringCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + }{ + {"empty", ""}, + {"single_char", "a"}, + {"ascii", "example.com"}, + {"utf8", "测试域名.中国"}, + {"special_chars", "\x00\xff\n\t"}, + {"127_bytes", strings.Repeat("x", 127)}, + {"128_bytes", strings.Repeat("x", 128)}, + {"16383_bytes", strings.Repeat("x", 16383)}, + {"16384_bytes", strings.Repeat("x", 16384)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Old write + var oldBuf bytes.Buffer + err := oldWriteString(&oldBuf, tc.input) + require.NoError(t, err) + + // New write + var newBuf bytes.Buffer + err = writeString(&newBuf, tc.input) + require.NoError(t, err) + + // Bytes must match + require.Equal(t, oldBuf.Bytes(), newBuf.Bytes(), + "mismatch for %q\nold: %x\nnew: %x", tc.name, oldBuf.Bytes(), newBuf.Bytes()) + + // New write -> old read + readBack, err := oldReadString(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + require.Equal(t, tc.input, readBack) + + // Old write -> new read + readBack2, err := readString(bufio.NewReader(bytes.NewReader(oldBuf.Bytes()))) + require.NoError(t, err) + require.Equal(t, tc.input, readBack2) + }) + } +} + +func TestItemCompat(t *testing.T) { + t.Parallel() + + // Note: varbin.Write has a bug where struct values (not pointers) don't write their fields + // because field.CanSet() returns false for non-addressable values. + // The old geosite code passed Item values to varbin.Write, which silently wrote nothing. + // The new code correctly writes Type + Value using manual serialization. + // This test verifies the new serialization format and round-trip correctness. + + cases := []struct { + name string + input Item + }{ + {"domain_empty", Item{Type: RuleTypeDomain, Value: ""}}, + {"domain_normal", Item{Type: RuleTypeDomain, Value: "example.com"}}, + {"domain_suffix", Item{Type: RuleTypeDomainSuffix, Value: ".example.com"}}, + {"domain_keyword", Item{Type: RuleTypeDomainKeyword, Value: "google"}}, + {"domain_regex", Item{Type: RuleTypeDomainRegex, Value: `^.*\.example\.com$`}}, + {"utf8_domain", Item{Type: RuleTypeDomain, Value: "测试.com"}}, + {"long_domain", Item{Type: RuleTypeDomainSuffix, Value: strings.Repeat("a", 200) + ".com"}}, + {"128_bytes_value", Item{Type: RuleTypeDomain, Value: strings.Repeat("x", 128)}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // New write + var newBuf bytes.Buffer + err := newBuf.WriteByte(byte(tc.input.Type)) + require.NoError(t, err) + err = writeString(&newBuf, tc.input.Value) + require.NoError(t, err) + + // Verify format: Type (1 byte) + Value (uvarint len + bytes) + require.True(t, len(newBuf.Bytes()) >= 1, "output too short") + require.Equal(t, byte(tc.input.Type), newBuf.Bytes()[0], "type byte mismatch") + + // New write -> old read (varbin can read correctly when given addressable target) + readBack, err := oldReadItem(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + require.Equal(t, tc.input, readBack) + + // New write -> new read + reader := bufio.NewReader(bytes.NewReader(newBuf.Bytes())) + typeByte, err := reader.ReadByte() + require.NoError(t, err) + value, err := readString(reader) + require.NoError(t, err) + require.Equal(t, tc.input, Item{Type: ItemType(typeByte), Value: value}) + }) + } +} + +func TestGeositeWriteReadCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input map[string][]Item + }{ + { + "empty_map", + map[string][]Item{}, + }, + { + "single_code_empty_items", + map[string][]Item{"test": {}}, + }, + { + "single_code_single_item", + map[string][]Item{"test": {{Type: RuleTypeDomain, Value: "a.com"}}}, + }, + { + "single_code_multi_items", + map[string][]Item{ + "test": { + {Type: RuleTypeDomain, Value: "a.com"}, + {Type: RuleTypeDomainSuffix, Value: ".b.com"}, + {Type: RuleTypeDomainKeyword, Value: "keyword"}, + {Type: RuleTypeDomainRegex, Value: `^.*$`}, + }, + }, + }, + { + "multi_code", + map[string][]Item{ + "cn": {{Type: RuleTypeDomain, Value: "baidu.com"}, {Type: RuleTypeDomainSuffix, Value: ".cn"}}, + "us": {{Type: RuleTypeDomain, Value: "google.com"}}, + "jp": {{Type: RuleTypeDomainSuffix, Value: ".jp"}}, + }, + }, + { + "utf8_values", + map[string][]Item{ + "test": { + {Type: RuleTypeDomain, Value: "测试.中国"}, + {Type: RuleTypeDomainSuffix, Value: ".テスト"}, + }, + }, + }, + { + "large_items", + generateLargeItems(1000), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Write using new implementation + var buf bytes.Buffer + err := Write(&buf, tc.input) + require.NoError(t, err) + + // Read back and verify + reader, codes, err := NewReader(bytes.NewReader(buf.Bytes())) + require.NoError(t, err) + + // Verify all codes exist + codeSet := make(map[string]bool) + for _, code := range codes { + codeSet[code] = true + } + for code := range tc.input { + require.True(t, codeSet[code], "missing code: %s", code) + } + + // Verify items match + for code, expectedItems := range tc.input { + items, err := reader.Read(code) + require.NoError(t, err) + require.Equal(t, expectedItems, items, "items mismatch for code: %s", code) + } + }) + } +} + +func generateLargeItems(count int) map[string][]Item { + items := make([]Item, count) + for i := 0; i < count; i++ { + items[i] = Item{ + Type: ItemType(i % 4), + Value: strings.Repeat("x", i%200) + ".com", + } + } + return map[string][]Item{"large": items} +} diff --git a/common/geosite/geosite_test.go b/common/geosite/geosite_test.go new file mode 100644 index 00000000..bdcb7a7a --- /dev/null +++ b/common/geosite/geosite_test.go @@ -0,0 +1,34 @@ +package geosite_test + +import ( + "bytes" + "testing" + + "github.com/sagernet/sing-box/common/geosite" + + "github.com/stretchr/testify/require" +) + +func TestGeosite(t *testing.T) { + t.Parallel() + + var buffer bytes.Buffer + err := geosite.Write(&buffer, map[string][]geosite.Item{ + "test": { + { + Type: geosite.RuleTypeDomain, + Value: "example.org", + }, + }, + }) + require.NoError(t, err) + reader, codes, err := geosite.NewReader(bytes.NewReader(buffer.Bytes())) + require.NoError(t, err) + require.Equal(t, []string{"test"}, codes) + items, err := reader.Read("test") + require.NoError(t, err) + require.Equal(t, []geosite.Item{{ + Type: geosite.RuleTypeDomain, + Value: "example.org", + }}, items) +} diff --git a/common/geosite/reader.go b/common/geosite/reader.go new file mode 100644 index 00000000..ef99837d --- /dev/null +++ b/common/geosite/reader.go @@ -0,0 +1,158 @@ +package geosite + +import ( + "bufio" + "encoding/binary" + "io" + "os" + "sync" + "sync/atomic" + + E "github.com/sagernet/sing/common/exceptions" +) + +type Reader struct { + access sync.Mutex + reader io.ReadSeeker + bufferedReader *bufio.Reader + metadataIndex int64 + domainIndex map[string]int + domainLength map[string]int +} + +func Open(path string) (*Reader, []string, error) { + content, err := os.Open(path) + if err != nil { + return nil, nil, err + } + reader, codes, err := NewReader(content) + if err != nil { + content.Close() + return nil, nil, err + } + return reader, codes, nil +} + +func NewReader(readSeeker io.ReadSeeker) (*Reader, []string, error) { + reader := &Reader{ + reader: readSeeker, + } + err := reader.readMetadata() + if err != nil { + return nil, nil, err + } + codes := make([]string, 0, len(reader.domainIndex)) + for code := range reader.domainIndex { + codes = append(codes, code) + } + return reader, codes, nil +} + +type geositeMetadata struct { + Code string + Index uint64 + Length uint64 +} + +func (r *Reader) readMetadata() error { + counter := &readCounter{Reader: r.reader} + reader := bufio.NewReader(counter) + version, err := reader.ReadByte() + if err != nil { + return err + } + if version != 0 { + return E.New("unknown version") + } + entryLength, err := binary.ReadUvarint(reader) + if err != nil { + return err + } + keys := make([]string, entryLength) + domainIndex := make(map[string]int) + domainLength := make(map[string]int) + for i := 0; i < int(entryLength); i++ { + var ( + code string + codeIndex uint64 + codeLength uint64 + ) + code, err = readString(reader) + if err != nil { + return err + } + keys[i] = code + codeIndex, err = binary.ReadUvarint(reader) + if err != nil { + return err + } + codeLength, err = binary.ReadUvarint(reader) + if err != nil { + return err + } + domainIndex[code] = int(codeIndex) + domainLength[code] = int(codeLength) + } + r.domainIndex = domainIndex + r.domainLength = domainLength + r.metadataIndex = counter.count - int64(reader.Buffered()) + r.bufferedReader = reader + return nil +} + +func (r *Reader) Read(code string) ([]Item, error) { + index, exists := r.domainIndex[code] + if !exists { + return nil, E.New("code ", code, " not exists!") + } + _, err := r.reader.Seek(r.metadataIndex+int64(index), io.SeekStart) + if err != nil { + return nil, err + } + r.bufferedReader.Reset(r.reader) + itemList := make([]Item, r.domainLength[code]) + for i := range itemList { + typeByte, err := r.bufferedReader.ReadByte() + if err != nil { + return nil, err + } + itemList[i].Type = ItemType(typeByte) + itemList[i].Value, err = readString(r.bufferedReader) + if err != nil { + return nil, err + } + } + return itemList, nil +} + +func (r *Reader) Upstream() any { + return r.reader +} + +type readCounter struct { + io.Reader + count int64 +} + +func (r *readCounter) Read(p []byte) (n int, err error) { + n, err = r.Reader.Read(p) + if n > 0 { + atomic.AddInt64(&r.count, int64(n)) + } + return +} + +func readString(reader io.ByteReader) (string, error) { + length, err := binary.ReadUvarint(reader) + if err != nil { + return "", err + } + bytes := make([]byte, length) + for i := range bytes { + bytes[i], err = reader.ReadByte() + if err != nil { + return "", err + } + } + return string(bytes), nil +} diff --git a/common/geosite/rule.go b/common/geosite/rule.go new file mode 100644 index 00000000..55287156 --- /dev/null +++ b/common/geosite/rule.go @@ -0,0 +1,103 @@ +package geosite + +import "github.com/sagernet/sing-box/option" + +type ItemType = uint8 + +const ( + RuleTypeDomain ItemType = iota + RuleTypeDomainSuffix + RuleTypeDomainKeyword + RuleTypeDomainRegex +) + +type Item struct { + Type ItemType + Value string +} + +func Compile(code []Item) option.DefaultRule { + var domainLength int + var domainSuffixLength int + var domainKeywordLength int + var domainRegexLength int + for _, item := range code { + switch item.Type { + case RuleTypeDomain: + domainLength++ + case RuleTypeDomainSuffix: + domainSuffixLength++ + case RuleTypeDomainKeyword: + domainKeywordLength++ + case RuleTypeDomainRegex: + domainRegexLength++ + } + } + var codeRule option.DefaultRule + if domainLength > 0 { + codeRule.Domain = make([]string, 0, domainLength) + } + if domainSuffixLength > 0 { + codeRule.DomainSuffix = make([]string, 0, domainSuffixLength) + } + if domainKeywordLength > 0 { + codeRule.DomainKeyword = make([]string, 0, domainKeywordLength) + } + if domainRegexLength > 0 { + codeRule.DomainRegex = make([]string, 0, domainRegexLength) + } + for _, item := range code { + switch item.Type { + case RuleTypeDomain: + codeRule.Domain = append(codeRule.Domain, item.Value) + case RuleTypeDomainSuffix: + codeRule.DomainSuffix = append(codeRule.DomainSuffix, item.Value) + case RuleTypeDomainKeyword: + codeRule.DomainKeyword = append(codeRule.DomainKeyword, item.Value) + case RuleTypeDomainRegex: + codeRule.DomainRegex = append(codeRule.DomainRegex, item.Value) + } + } + return codeRule +} + +func Merge(rules []option.DefaultRule) option.DefaultRule { + var domainLength int + var domainSuffixLength int + var domainKeywordLength int + var domainRegexLength int + for _, subRule := range rules { + domainLength += len(subRule.Domain) + domainSuffixLength += len(subRule.DomainSuffix) + domainKeywordLength += len(subRule.DomainKeyword) + domainRegexLength += len(subRule.DomainRegex) + } + var rule option.DefaultRule + if domainLength > 0 { + rule.Domain = make([]string, 0, domainLength) + } + if domainSuffixLength > 0 { + rule.DomainSuffix = make([]string, 0, domainSuffixLength) + } + if domainKeywordLength > 0 { + rule.DomainKeyword = make([]string, 0, domainKeywordLength) + } + if domainRegexLength > 0 { + rule.DomainRegex = make([]string, 0, domainRegexLength) + } + for _, subRule := range rules { + if len(subRule.Domain) > 0 { + rule.Domain = append(rule.Domain, subRule.Domain...) + } + if len(subRule.DomainSuffix) > 0 { + rule.DomainSuffix = append(rule.DomainSuffix, subRule.DomainSuffix...) + } + if len(subRule.DomainKeyword) > 0 { + rule.DomainKeyword = append(rule.DomainKeyword, subRule.DomainKeyword...) + } + if len(subRule.DomainRegex) > 0 { + rule.DomainRegex = append(rule.DomainRegex, subRule.DomainRegex...) + } + } + return rule +} diff --git a/common/geosite/writer.go b/common/geosite/writer.go new file mode 100644 index 00000000..52f2f7b9 --- /dev/null +++ b/common/geosite/writer.go @@ -0,0 +1,73 @@ +package geosite + +import ( + "bytes" + "sort" + + "github.com/sagernet/sing/common/varbin" +) + +func Write(writer varbin.Writer, domains map[string][]Item) error { + keys := make([]string, 0, len(domains)) + for code := range domains { + keys = append(keys, code) + } + sort.Strings(keys) + + content := &bytes.Buffer{} + index := make(map[string]int) + for _, code := range keys { + index[code] = content.Len() + for _, item := range domains[code] { + err := content.WriteByte(byte(item.Type)) + if err != nil { + return err + } + err = writeString(content, item.Value) + if err != nil { + return err + } + } + } + + err := writer.WriteByte(0) + if err != nil { + return err + } + + _, err = varbin.WriteUvarint(writer, uint64(len(keys))) + if err != nil { + return err + } + + for _, code := range keys { + err = writeString(writer, code) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(index[code])) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(domains[code]))) + if err != nil { + return err + } + } + + _, err = writer.Write(content.Bytes()) + if err != nil { + return err + } + + return nil +} + +func writeString(writer varbin.Writer, value string) error { + _, err := varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + _, err = writer.Write([]byte(value)) + return err +} diff --git a/common/interrupt/conn.go b/common/interrupt/conn.go new file mode 100644 index 00000000..6a6d31c6 --- /dev/null +++ b/common/interrupt/conn.go @@ -0,0 +1,75 @@ +package interrupt + +import ( + "net" + + "github.com/sagernet/sing/common/x/list" +) + +/*type GroupedConn interface { + MarkAsInternal() +} + +func MarkAsInternal(conn any) { + if groupedConn, isGroupConn := common.Cast[GroupedConn](conn); isGroupConn { + groupedConn.MarkAsInternal() + } +}*/ + +type Conn struct { + net.Conn + group *Group + element *list.Element[*groupConnItem] +} + +/*func (c *Conn) MarkAsInternal() { + c.element.Value.internal = true +}*/ + +func (c *Conn) Close() error { + c.group.access.Lock() + defer c.group.access.Unlock() + c.group.connections.Remove(c.element) + return c.Conn.Close() +} + +func (c *Conn) ReaderReplaceable() bool { + return true +} + +func (c *Conn) WriterReplaceable() bool { + return true +} + +func (c *Conn) Upstream() any { + return c.Conn +} + +type PacketConn struct { + net.PacketConn + group *Group + element *list.Element[*groupConnItem] +} + +/*func (c *PacketConn) MarkAsInternal() { + c.element.Value.internal = true +}*/ + +func (c *PacketConn) Close() error { + c.group.access.Lock() + defer c.group.access.Unlock() + c.group.connections.Remove(c.element) + return c.PacketConn.Close() +} + +func (c *PacketConn) ReaderReplaceable() bool { + return true +} + +func (c *PacketConn) WriterReplaceable() bool { + return true +} + +func (c *PacketConn) Upstream() any { + return c.PacketConn +} diff --git a/common/interrupt/context.go b/common/interrupt/context.go new file mode 100644 index 00000000..44726b2d --- /dev/null +++ b/common/interrupt/context.go @@ -0,0 +1,13 @@ +package interrupt + +import "context" + +type contextKeyIsExternalConnection struct{} + +func ContextWithIsExternalConnection(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKeyIsExternalConnection{}, true) +} + +func IsExternalConnectionFromContext(ctx context.Context) bool { + return ctx.Value(contextKeyIsExternalConnection{}) != nil +} diff --git a/common/interrupt/group.go b/common/interrupt/group.go new file mode 100644 index 00000000..ba2e7f73 --- /dev/null +++ b/common/interrupt/group.go @@ -0,0 +1,52 @@ +package interrupt + +import ( + "io" + "net" + "sync" + + "github.com/sagernet/sing/common/x/list" +) + +type Group struct { + access sync.Mutex + connections list.List[*groupConnItem] +} + +type groupConnItem struct { + conn io.Closer + isExternal bool +} + +func NewGroup() *Group { + return &Group{} +} + +func (g *Group) NewConn(conn net.Conn, isExternal bool) net.Conn { + g.access.Lock() + defer g.access.Unlock() + item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + return &Conn{Conn: conn, group: g, element: item} +} + +func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketConn { + g.access.Lock() + defer g.access.Unlock() + item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + return &PacketConn{PacketConn: conn, group: g, element: item} +} + +func (g *Group) Interrupt(interruptExternalConnections bool) { + g.access.Lock() + defer g.access.Unlock() + var toDelete []*list.Element[*groupConnItem] + for element := g.connections.Front(); element != nil; element = element.Next() { + if !element.Value.isExternal || interruptExternalConnections { + element.Value.conn.Close() + toDelete = append(toDelete, element) + } + } + for _, element := range toDelete { + g.connections.Remove(element) + } +} diff --git a/common/ja3/LICENSE b/common/ja3/LICENSE new file mode 100644 index 00000000..b42ec95d --- /dev/null +++ b/common/ja3/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, Open Systems AG +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/common/ja3/README.md b/common/ja3/README.md new file mode 100644 index 00000000..5c0bd8ae --- /dev/null +++ b/common/ja3/README.md @@ -0,0 +1,3 @@ +# JA3 + +mod from: https://github.com/open-ch/ja3 \ No newline at end of file diff --git a/common/ja3/error.go b/common/ja3/error.go new file mode 100644 index 00000000..cab85492 --- /dev/null +++ b/common/ja3/error.go @@ -0,0 +1,31 @@ +// Copyright (c) 2018, Open Systems AG. All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file in the root of the source +// tree. + +package ja3 + +import "fmt" + +// Error types +const ( + LengthErr string = "length check %v failed" + ContentTypeErr string = "content type not matching" + VersionErr string = "version check %v failed" + HandshakeTypeErr string = "handshake type not matching" + SNITypeErr string = "SNI type not supported" +) + +// ParseError can be encountered while parsing a segment +type ParseError struct { + errType string + check int +} + +func (e *ParseError) Error() string { + if e.errType == LengthErr || e.errType == VersionErr { + return fmt.Sprintf(e.errType, e.check) + } + return fmt.Sprint(e.errType) +} diff --git a/common/ja3/ja3.go b/common/ja3/ja3.go new file mode 100644 index 00000000..608819fe --- /dev/null +++ b/common/ja3/ja3.go @@ -0,0 +1,83 @@ +// Copyright (c) 2018, Open Systems AG. All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file in the root of the source +// tree. + +package ja3 + +import ( + "crypto/md5" + "encoding/hex" + + "golang.org/x/exp/slices" +) + +type ClientHello struct { + Version uint16 + CipherSuites []uint16 + Extensions []uint16 + EllipticCurves []uint16 + EllipticCurvePF []uint8 + Versions []uint16 + SignatureAlgorithms []uint16 + ServerName string + ja3ByteString []byte + ja3Hash string +} + +func (j *ClientHello) Equals(another *ClientHello, ignoreExtensionsSequence bool) bool { + if j.Version != another.Version { + return false + } + if !slices.Equal(j.CipherSuites, another.CipherSuites) { + return false + } + if !ignoreExtensionsSequence && !slices.Equal(j.Extensions, another.Extensions) { + return false + } + if ignoreExtensionsSequence && !slices.Equal(j.Extensions, another.sortedExtensions()) { + return false + } + if !slices.Equal(j.EllipticCurves, another.EllipticCurves) { + return false + } + if !slices.Equal(j.EllipticCurvePF, another.EllipticCurvePF) { + return false + } + if !slices.Equal(j.SignatureAlgorithms, another.SignatureAlgorithms) { + return false + } + return true +} + +func (j *ClientHello) sortedExtensions() []uint16 { + extensions := make([]uint16, len(j.Extensions)) + copy(extensions, j.Extensions) + slices.Sort(extensions) + return extensions +} + +func Compute(payload []byte) (*ClientHello, error) { + ja3 := ClientHello{} + err := ja3.parseSegment(payload) + return &ja3, err +} + +func (j *ClientHello) String() string { + if j.ja3ByteString == nil { + j.marshalJA3() + } + return string(j.ja3ByteString) +} + +func (j *ClientHello) Hash() string { + if j.ja3ByteString == nil { + j.marshalJA3() + } + if j.ja3Hash == "" { + h := md5.Sum(j.ja3ByteString) + j.ja3Hash = hex.EncodeToString(h[:]) + } + return j.ja3Hash +} diff --git a/common/ja3/parser.go b/common/ja3/parser.go new file mode 100644 index 00000000..f9cca603 --- /dev/null +++ b/common/ja3/parser.go @@ -0,0 +1,357 @@ +// Copyright (c) 2018, Open Systems AG. All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file in the root of the source +// tree. + +package ja3 + +import ( + "encoding/binary" + "strconv" +) + +const ( + // Constants used for parsing + recordLayerHeaderLen int = 5 + handshakeHeaderLen int = 6 + randomDataLen int = 32 + sessionIDHeaderLen int = 1 + cipherSuiteHeaderLen int = 2 + compressMethodHeaderLen int = 1 + extensionsHeaderLen int = 2 + extensionHeaderLen int = 4 + sniExtensionHeaderLen int = 5 + ecExtensionHeaderLen int = 2 + ecpfExtensionHeaderLen int = 1 + versionExtensionHeaderLen int = 1 + signatureAlgorithmsExtensionHeaderLen int = 2 + contentType uint8 = 22 + handshakeType uint8 = 1 + sniExtensionType uint16 = 0 + sniNameDNSHostnameType uint8 = 0 + ecExtensionType uint16 = 10 + ecpfExtensionType uint16 = 11 + versionExtensionType uint16 = 43 + signatureAlgorithmsExtensionType uint16 = 13 + + // Versions + // The bitmask covers the versions SSL3.0 to TLS1.2 + tlsVersionBitmask uint16 = 0xFFFC + tls13 uint16 = 0x0304 + + // GREASE values + // The bitmask covers all GREASE values + GreaseBitmask uint16 = 0x0F0F + + // Constants used for marshalling + dashByte = byte(45) + commaByte = byte(44) +) + +// parseSegment to populate the corresponding ClientHello object or return an error +func (j *ClientHello) parseSegment(segment []byte) error { + // Check if we can decode the next fields + if len(segment) < recordLayerHeaderLen { + return &ParseError{LengthErr, 1} + } + + // Check if we have "Content Type: Handshake (22)" + contType := uint8(segment[0]) + if contType != contentType { + return &ParseError{errType: ContentTypeErr} + } + + // Check if TLS record layer version is supported + tlsRecordVersion := uint16(segment[1])<<8 | uint16(segment[2]) + if tlsRecordVersion&tlsVersionBitmask != 0x0300 && tlsRecordVersion != tls13 { + return &ParseError{VersionErr, 1} + } + + // Check that the Handshake is as long as expected from the length field + segmentLen := uint16(segment[3])<<8 | uint16(segment[4]) + if len(segment[recordLayerHeaderLen:]) < int(segmentLen) { + return &ParseError{LengthErr, 2} + } + // Keep the Handshake messege, ignore any additional following record types + hs := segment[recordLayerHeaderLen : recordLayerHeaderLen+int(segmentLen)] + + err := j.parseHandshake(hs) + + return err +} + +// parseHandshake body +func (j *ClientHello) parseHandshake(hs []byte) error { + // Check if we can decode the next fields + if len(hs) < handshakeHeaderLen+randomDataLen+sessionIDHeaderLen { + return &ParseError{LengthErr, 3} + } + + // Check if we have "Handshake Type: Client Hello (1)" + handshType := uint8(hs[0]) + if handshType != handshakeType { + return &ParseError{errType: HandshakeTypeErr} + } + + // Check if actual length of handshake matches (this is a great exclusion criterion for false positives, + // as these fields have to match the actual length of the rest of the segment) + handshakeLen := uint32(hs[1])<<16 | uint32(hs[2])<<8 | uint32(hs[3]) + if len(hs[4:]) != int(handshakeLen) { + return &ParseError{LengthErr, 4} + } + + // Check if Client Hello version is supported + tlsVersion := uint16(hs[4])<<8 | uint16(hs[5]) + if tlsVersion&tlsVersionBitmask != 0x0300 && tlsVersion != tls13 { + return &ParseError{VersionErr, 2} + } + j.Version = tlsVersion + + // Check if we can decode the next fields + sessionIDLen := uint8(hs[38]) + if len(hs) < handshakeHeaderLen+randomDataLen+sessionIDHeaderLen+int(sessionIDLen) { + return &ParseError{LengthErr, 5} + } + + // Cipher Suites + cs := hs[handshakeHeaderLen+randomDataLen+sessionIDHeaderLen+int(sessionIDLen):] + + // Check if we can decode the next fields + if len(cs) < cipherSuiteHeaderLen { + return &ParseError{LengthErr, 6} + } + + csLen := uint16(cs[0])<<8 | uint16(cs[1]) + numCiphers := int(csLen / 2) + cipherSuites := make([]uint16, 0, numCiphers) + + // Check if we can decode the next fields + if len(cs) < cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen { + return &ParseError{LengthErr, 7} + } + + for i := 0; i < numCiphers; i++ { + cipherSuite := uint16(cs[2+i<<1])<<8 | uint16(cs[3+i<<1]) + cipherSuites = append(cipherSuites, cipherSuite) + } + j.CipherSuites = cipherSuites + + // Check if we can decode the next fields + compressMethodLen := uint16(cs[cipherSuiteHeaderLen+int(csLen)]) + if len(cs) < cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen+int(compressMethodLen) { + return &ParseError{LengthErr, 8} + } + + // Extensions + exs := cs[cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen+int(compressMethodLen):] + + err := j.parseExtensions(exs) + + return err +} + +// parseExtensions of the handshake +func (j *ClientHello) parseExtensions(exs []byte) error { + // Check for no extensions, this fields header is nonexistent if no body is used + if len(exs) == 0 { + return nil + } + + // Check if we can decode the next fields + if len(exs) < extensionsHeaderLen { + return &ParseError{LengthErr, 9} + } + + exsLen := uint16(exs[0])<<8 | uint16(exs[1]) + exs = exs[extensionsHeaderLen:] + + // Check if we can decode the next fields + if len(exs) < int(exsLen) { + return &ParseError{LengthErr, 10} + } + + var sni []byte + var extensions, ellipticCurves []uint16 + var ellipticCurvePF []uint8 + var versions []uint16 + var signatureAlgorithms []uint16 + for len(exs) > 0 { + + // Check if we can decode the next fields + if len(exs) < extensionHeaderLen { + return &ParseError{LengthErr, 11} + } + + exType := uint16(exs[0])<<8 | uint16(exs[1]) + exLen := uint16(exs[2])<<8 | uint16(exs[3]) + // Ignore any GREASE extensions + extensions = append(extensions, exType) + // Check if we can decode the next fields + if len(exs) < extensionHeaderLen+int(exLen) { + return &ParseError{LengthErr, 12} + } + + sex := exs[extensionHeaderLen : extensionHeaderLen+int(exLen)] + + switch exType { + case sniExtensionType: // Extensions: server_name + + // Check if we can decode the next fields + if len(sex) < sniExtensionHeaderLen { + return &ParseError{LengthErr, 13} + } + + sniType := uint8(sex[2]) + sniLen := uint16(sex[3])<<8 | uint16(sex[4]) + sex = sex[sniExtensionHeaderLen:] + + // Check if we can decode the next fields + if len(sex) != int(sniLen) { + return &ParseError{LengthErr, 14} + } + + switch sniType { + case sniNameDNSHostnameType: + sni = sex + default: + return &ParseError{errType: SNITypeErr} + } + case ecExtensionType: // Extensions: supported_groups + + // Check if we can decode the next fields + if len(sex) < ecExtensionHeaderLen { + return &ParseError{LengthErr, 15} + } + + ecsLen := uint16(sex[0])<<8 | uint16(sex[1]) + numCurves := int(ecsLen / 2) + ellipticCurves = make([]uint16, 0, numCurves) + sex = sex[ecExtensionHeaderLen:] + + // Check if we can decode the next fields + if len(sex) != int(ecsLen) { + return &ParseError{LengthErr, 16} + } + + for i := 0; i < numCurves; i++ { + ecType := uint16(sex[i*2])<<8 | uint16(sex[1+i*2]) + ellipticCurves = append(ellipticCurves, ecType) + } + + case ecpfExtensionType: // Extensions: ec_point_formats + + // Check if we can decode the next fields + if len(sex) < ecpfExtensionHeaderLen { + return &ParseError{LengthErr, 17} + } + + ecpfsLen := uint8(sex[0]) + numPF := int(ecpfsLen) + ellipticCurvePF = make([]uint8, numPF) + sex = sex[ecpfExtensionHeaderLen:] + + // Check if we can decode the next fields + if len(sex) != numPF { + return &ParseError{LengthErr, 18} + } + + for i := 0; i < numPF; i++ { + ellipticCurvePF[i] = uint8(sex[i]) + } + case versionExtensionType: + if len(sex) < versionExtensionHeaderLen { + return &ParseError{LengthErr, 19} + } + versionsLen := int(sex[0]) + for i := 0; i < versionsLen; i += 2 { + versions = append(versions, binary.BigEndian.Uint16(sex[1:][i:])) + } + case signatureAlgorithmsExtensionType: + if len(sex) < signatureAlgorithmsExtensionHeaderLen { + return &ParseError{LengthErr, 20} + } + ssaLen := binary.BigEndian.Uint16(sex) + for i := 0; i < int(ssaLen); i += 2 { + signatureAlgorithms = append(signatureAlgorithms, binary.BigEndian.Uint16(sex[2:][i:])) + } + } + exs = exs[4+exLen:] + } + j.ServerName = string(sni) + j.Extensions = extensions + j.EllipticCurves = ellipticCurves + j.EllipticCurvePF = ellipticCurvePF + j.Versions = versions + j.SignatureAlgorithms = signatureAlgorithms + return nil +} + +// marshalJA3 into a byte string +func (j *ClientHello) marshalJA3() { + // An uint16 can contain numbers with up to 5 digits and an uint8 can contain numbers with up to 3 digits, but we + // also need a byte for each separating character, except at the end. + byteStringLen := 6*(1+len(j.CipherSuites)+len(j.Extensions)+len(j.EllipticCurves)) + 4*len(j.EllipticCurvePF) - 1 + byteString := make([]byte, 0, byteStringLen) + + // Version + byteString = strconv.AppendUint(byteString, uint64(j.Version), 10) + byteString = append(byteString, commaByte) + + // Cipher Suites + if len(j.CipherSuites) != 0 { + for _, val := range j.CipherSuites { + if val&GreaseBitmask != 0x0A0A { + continue + } + byteString = strconv.AppendUint(byteString, uint64(val), 10) + byteString = append(byteString, dashByte) + } + // Replace last dash with a comma + byteString[len(byteString)-1] = commaByte + } else { + byteString = append(byteString, commaByte) + } + + // Extensions + if len(j.Extensions) != 0 { + for _, val := range j.Extensions { + if val&GreaseBitmask != 0x0A0A { + continue + } + byteString = strconv.AppendUint(byteString, uint64(val), 10) + byteString = append(byteString, dashByte) + } + // Replace last dash with a comma + byteString[len(byteString)-1] = commaByte + } else { + byteString = append(byteString, commaByte) + } + + // Elliptic curves + if len(j.EllipticCurves) != 0 { + for _, val := range j.EllipticCurves { + if val&GreaseBitmask != 0x0A0A { + continue + } + byteString = strconv.AppendUint(byteString, uint64(val), 10) + byteString = append(byteString, dashByte) + } + // Replace last dash with a comma + byteString[len(byteString)-1] = commaByte + } else { + byteString = append(byteString, commaByte) + } + + // ECPF + if len(j.EllipticCurvePF) != 0 { + for _, val := range j.EllipticCurvePF { + byteString = strconv.AppendUint(byteString, uint64(val), 10) + byteString = append(byteString, dashByte) + } + // Remove last dash + byteString = byteString[:len(byteString)-1] + } + + j.ja3ByteString = byteString +} diff --git a/common/ktls/ktls.go b/common/ktls/ktls.go new file mode 100644 index 00000000..33a59b13 --- /dev/null +++ b/common/ktls/ktls.go @@ -0,0 +1,133 @@ +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "io" + "net" + "os" + "syscall" + + "github.com/sagernet/sing-box/common/badtls" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + + "golang.org/x/sys/unix" +) + +type Conn struct { + aTLS.Conn + ctx context.Context + logger logger.ContextLogger + conn net.Conn + rawConn *badtls.RawConn + syscallConn syscall.Conn + rawSyscallConn syscall.RawConn + readWaitOptions N.ReadWaitOptions + kernelTx bool + kernelRx bool + pendingRxSplice bool +} + +func NewConn(ctx context.Context, logger logger.ContextLogger, conn aTLS.Conn, txOffload, rxOffload bool) (aTLS.Conn, error) { + err := Load() + if err != nil { + return nil, err + } + syscallConn, isSyscallConn := N.CastReader[interface { + io.Reader + syscall.Conn + }](conn.NetConn()) + if !isSyscallConn { + return nil, os.ErrInvalid + } + rawSyscallConn, err := syscallConn.SyscallConn() + if err != nil { + return nil, err + } + rawConn, err := badtls.NewRawConn(conn) + if err != nil { + return nil, err + } + if *rawConn.Vers != tls.VersionTLS13 { + return nil, os.ErrInvalid + } + for rawConn.RawInput.Len() > 0 { + err = rawConn.ReadRecord() + if err != nil { + return nil, err + } + for rawConn.Hand.Len() > 0 { + err = rawConn.HandlePostHandshakeMessage() + if err != nil { + return nil, E.Cause(err, "handle post-handshake messages") + } + } + } + kConn := &Conn{ + Conn: conn, + ctx: ctx, + logger: logger, + conn: conn.NetConn(), + rawConn: rawConn, + syscallConn: syscallConn, + rawSyscallConn: rawSyscallConn, + } + err = kConn.setupKernel(txOffload, rxOffload) + if err != nil { + return nil, err + } + return kConn, nil +} + +func (c *Conn) Upstream() any { + return c.Conn +} + +func (c *Conn) SyscallConnForRead() syscall.RawConn { + if !c.kernelRx { + return nil + } + if !*c.rawConn.IsClient { + c.logger.WarnContext(c.ctx, "ktls: RX splice is unavailable on the server size, since it will cause an unknown failure") + return nil + } + c.logger.DebugContext(c.ctx, "ktls: RX splice requested") + return c.rawSyscallConn +} + +func (c *Conn) HandleSyscallReadError(inputErr error) ([]byte, error) { + if errors.Is(inputErr, unix.EINVAL) { + c.pendingRxSplice = true + err := c.readRecord() + if err != nil { + return nil, E.Cause(err, "ktls: handle non-application-data record") + } + var input bytes.Buffer + if c.rawConn.Input.Len() > 0 { + _, err = c.rawConn.Input.WriteTo(&input) + if err != nil { + return nil, err + } + } + return input.Bytes(), nil + } else if errors.Is(inputErr, unix.EBADMSG) { + return nil, c.rawConn.In.SetErrorLocked(c.sendAlert(alertBadRecordMAC)) + } else { + return nil, E.Cause(inputErr, "ktls: unexpected errno") + } +} + +func (c *Conn) SyscallConnForWrite() syscall.RawConn { + if !c.kernelTx { + return nil + } + c.logger.DebugContext(c.ctx, "ktls: TX splice requested") + return c.rawSyscallConn +} diff --git a/common/ktls/ktls_alert.go b/common/ktls/ktls_alert.go new file mode 100644 index 00000000..e755feae --- /dev/null +++ b/common/ktls/ktls_alert.go @@ -0,0 +1,80 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "crypto/tls" + "net" +) + +const ( + // alert level + alertLevelWarning = 1 + alertLevelError = 2 +) + +const ( + alertCloseNotify = 0 + alertUnexpectedMessage = 10 + alertBadRecordMAC = 20 + alertDecryptionFailed = 21 + alertRecordOverflow = 22 + alertDecompressionFailure = 30 + alertHandshakeFailure = 40 + alertBadCertificate = 42 + alertUnsupportedCertificate = 43 + alertCertificateRevoked = 44 + alertCertificateExpired = 45 + alertCertificateUnknown = 46 + alertIllegalParameter = 47 + alertUnknownCA = 48 + alertAccessDenied = 49 + alertDecodeError = 50 + alertDecryptError = 51 + alertExportRestriction = 60 + alertProtocolVersion = 70 + alertInsufficientSecurity = 71 + alertInternalError = 80 + alertInappropriateFallback = 86 + alertUserCanceled = 90 + alertNoRenegotiation = 100 + alertMissingExtension = 109 + alertUnsupportedExtension = 110 + alertCertificateUnobtainable = 111 + alertUnrecognizedName = 112 + alertBadCertificateStatusResponse = 113 + alertBadCertificateHashValue = 114 + alertUnknownPSKIdentity = 115 + alertCertificateRequired = 116 + alertNoApplicationProtocol = 120 + alertECHRequired = 121 +) + +func (c *Conn) sendAlertLocked(err uint8) error { + switch err { + case alertNoRenegotiation, alertCloseNotify: + c.rawConn.Tmp[0] = alertLevelWarning + default: + c.rawConn.Tmp[0] = alertLevelError + } + c.rawConn.Tmp[1] = byte(err) + + _, writeErr := c.writeRecordLocked(recordTypeAlert, c.rawConn.Tmp[0:2]) + if err == alertCloseNotify { + // closeNotify is a special case in that it isn't an error. + return writeErr + } + + return c.rawConn.Out.SetErrorLocked(&net.OpError{Op: "local error", Err: tls.AlertError(err)}) +} + +// sendAlert sends a TLS alert message. +func (c *Conn) sendAlert(err uint8) error { + c.rawConn.Out.Lock() + defer c.rawConn.Out.Unlock() + return c.sendAlertLocked(err) +} diff --git a/common/ktls/ktls_cipher_suites_linux.go b/common/ktls/ktls_cipher_suites_linux.go new file mode 100644 index 00000000..571f0251 --- /dev/null +++ b/common/ktls/ktls_cipher_suites_linux.go @@ -0,0 +1,326 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "crypto/tls" + "unsafe" + + "github.com/sagernet/sing-box/common/badtls" +) + +type kernelCryptoCipherType uint16 + +const ( + TLS_CIPHER_AES_GCM_128 kernelCryptoCipherType = 51 + TLS_CIPHER_AES_GCM_128_IV_SIZE kernelCryptoCipherType = 8 + TLS_CIPHER_AES_GCM_128_KEY_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_AES_GCM_128_SALT_SIZE kernelCryptoCipherType = 4 + TLS_CIPHER_AES_GCM_128_TAG_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + TLS_CIPHER_AES_GCM_256 kernelCryptoCipherType = 52 + TLS_CIPHER_AES_GCM_256_IV_SIZE kernelCryptoCipherType = 8 + TLS_CIPHER_AES_GCM_256_KEY_SIZE kernelCryptoCipherType = 32 + TLS_CIPHER_AES_GCM_256_SALT_SIZE kernelCryptoCipherType = 4 + TLS_CIPHER_AES_GCM_256_TAG_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_AES_GCM_256_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + TLS_CIPHER_AES_CCM_128 kernelCryptoCipherType = 53 + TLS_CIPHER_AES_CCM_128_IV_SIZE kernelCryptoCipherType = 8 + TLS_CIPHER_AES_CCM_128_KEY_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_AES_CCM_128_SALT_SIZE kernelCryptoCipherType = 4 + TLS_CIPHER_AES_CCM_128_TAG_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_AES_CCM_128_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + TLS_CIPHER_CHACHA20_POLY1305 kernelCryptoCipherType = 54 + TLS_CIPHER_CHACHA20_POLY1305_IV_SIZE kernelCryptoCipherType = 12 + TLS_CIPHER_CHACHA20_POLY1305_KEY_SIZE kernelCryptoCipherType = 32 + TLS_CIPHER_CHACHA20_POLY1305_SALT_SIZE kernelCryptoCipherType = 0 + TLS_CIPHER_CHACHA20_POLY1305_TAG_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_CHACHA20_POLY1305_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + // TLS_CIPHER_SM4_GCM kernelCryptoCipherType = 55 + // TLS_CIPHER_SM4_GCM_IV_SIZE kernelCryptoCipherType = 8 + // TLS_CIPHER_SM4_GCM_KEY_SIZE kernelCryptoCipherType = 16 + // TLS_CIPHER_SM4_GCM_SALT_SIZE kernelCryptoCipherType = 4 + // TLS_CIPHER_SM4_GCM_TAG_SIZE kernelCryptoCipherType = 16 + // TLS_CIPHER_SM4_GCM_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + // TLS_CIPHER_SM4_CCM kernelCryptoCipherType = 56 + // TLS_CIPHER_SM4_CCM_IV_SIZE kernelCryptoCipherType = 8 + // TLS_CIPHER_SM4_CCM_KEY_SIZE kernelCryptoCipherType = 16 + // TLS_CIPHER_SM4_CCM_SALT_SIZE kernelCryptoCipherType = 4 + // TLS_CIPHER_SM4_CCM_TAG_SIZE kernelCryptoCipherType = 16 + // TLS_CIPHER_SM4_CCM_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + TLS_CIPHER_ARIA_GCM_128 kernelCryptoCipherType = 57 + TLS_CIPHER_ARIA_GCM_128_IV_SIZE kernelCryptoCipherType = 8 + TLS_CIPHER_ARIA_GCM_128_KEY_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_ARIA_GCM_128_SALT_SIZE kernelCryptoCipherType = 4 + TLS_CIPHER_ARIA_GCM_128_TAG_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_ARIA_GCM_128_REC_SEQ_SIZE kernelCryptoCipherType = 8 + + TLS_CIPHER_ARIA_GCM_256 kernelCryptoCipherType = 58 + TLS_CIPHER_ARIA_GCM_256_IV_SIZE kernelCryptoCipherType = 8 + TLS_CIPHER_ARIA_GCM_256_KEY_SIZE kernelCryptoCipherType = 32 + TLS_CIPHER_ARIA_GCM_256_SALT_SIZE kernelCryptoCipherType = 4 + TLS_CIPHER_ARIA_GCM_256_TAG_SIZE kernelCryptoCipherType = 16 + TLS_CIPHER_ARIA_GCM_256_REC_SEQ_SIZE kernelCryptoCipherType = 8 +) + +type kernelCrypto interface { + String() string +} + +type kernelCryptoInfo struct { + version uint16 + cipher_type kernelCryptoCipherType +} + +var _ kernelCrypto = &kernelCryptoAES128GCM{} + +type kernelCryptoAES128GCM struct { + kernelCryptoInfo + iv [TLS_CIPHER_AES_GCM_128_IV_SIZE]byte + key [TLS_CIPHER_AES_GCM_128_KEY_SIZE]byte + salt [TLS_CIPHER_AES_GCM_128_SALT_SIZE]byte + rec_seq [TLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE]byte +} + +func (crypto *kernelCryptoAES128GCM) String() string { + crypto.cipher_type = TLS_CIPHER_AES_GCM_128 + return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +} + +var _ kernelCrypto = &kernelCryptoAES256GCM{} + +type kernelCryptoAES256GCM struct { + kernelCryptoInfo + iv [TLS_CIPHER_AES_GCM_256_IV_SIZE]byte + key [TLS_CIPHER_AES_GCM_256_KEY_SIZE]byte + salt [TLS_CIPHER_AES_GCM_256_SALT_SIZE]byte + rec_seq [TLS_CIPHER_AES_GCM_256_REC_SEQ_SIZE]byte +} + +func (crypto *kernelCryptoAES256GCM) String() string { + crypto.cipher_type = TLS_CIPHER_AES_GCM_256 + return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +} + +var _ kernelCrypto = &kernelCryptoAES128CCM{} + +type kernelCryptoAES128CCM struct { + kernelCryptoInfo + iv [TLS_CIPHER_AES_CCM_128_IV_SIZE]byte + key [TLS_CIPHER_AES_CCM_128_KEY_SIZE]byte + salt [TLS_CIPHER_AES_CCM_128_SALT_SIZE]byte + rec_seq [TLS_CIPHER_AES_CCM_128_REC_SEQ_SIZE]byte +} + +func (crypto *kernelCryptoAES128CCM) String() string { + crypto.cipher_type = TLS_CIPHER_AES_CCM_128 + return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +} + +var _ kernelCrypto = &kernelCryptoChacha20Poly1035{} + +type kernelCryptoChacha20Poly1035 struct { + kernelCryptoInfo + iv [TLS_CIPHER_CHACHA20_POLY1305_IV_SIZE]byte + key [TLS_CIPHER_CHACHA20_POLY1305_KEY_SIZE]byte + salt [TLS_CIPHER_CHACHA20_POLY1305_SALT_SIZE]byte + rec_seq [TLS_CIPHER_CHACHA20_POLY1305_REC_SEQ_SIZE]byte +} + +func (crypto *kernelCryptoChacha20Poly1035) String() string { + crypto.cipher_type = TLS_CIPHER_CHACHA20_POLY1305 + return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +} + +// var _ kernelCrypto = &kernelCryptoSM4GCM{} + +// type kernelCryptoSM4GCM struct { +// kernelCryptoInfo +// iv [TLS_CIPHER_SM4_GCM_IV_SIZE]byte +// key [TLS_CIPHER_SM4_GCM_KEY_SIZE]byte +// salt [TLS_CIPHER_SM4_GCM_SALT_SIZE]byte +// rec_seq [TLS_CIPHER_SM4_GCM_REC_SEQ_SIZE]byte +// } + +// func (crypto *kernelCryptoSM4GCM) String() string { +// crypto.cipher_type = TLS_CIPHER_SM4_GCM +// return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +// } + +// var _ kernelCrypto = &kernelCryptoSM4CCM{} + +// type kernelCryptoSM4CCM struct { +// kernelCryptoInfo +// iv [TLS_CIPHER_SM4_CCM_IV_SIZE]byte +// key [TLS_CIPHER_SM4_CCM_KEY_SIZE]byte +// salt [TLS_CIPHER_SM4_CCM_SALT_SIZE]byte +// rec_seq [TLS_CIPHER_SM4_CCM_REC_SEQ_SIZE]byte +// } + +// func (crypto *kernelCryptoSM4CCM) String() string { +// crypto.cipher_type = TLS_CIPHER_SM4_CCM +// return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +// } + +var _ kernelCrypto = &kernelCryptoARIA128GCM{} + +type kernelCryptoARIA128GCM struct { + kernelCryptoInfo + iv [TLS_CIPHER_ARIA_GCM_128_IV_SIZE]byte + key [TLS_CIPHER_ARIA_GCM_128_KEY_SIZE]byte + salt [TLS_CIPHER_ARIA_GCM_128_SALT_SIZE]byte + rec_seq [TLS_CIPHER_ARIA_GCM_128_REC_SEQ_SIZE]byte +} + +func (crypto *kernelCryptoARIA128GCM) String() string { + crypto.cipher_type = TLS_CIPHER_ARIA_GCM_128 + return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +} + +var _ kernelCrypto = &kernelCryptoARIA256GCM{} + +type kernelCryptoARIA256GCM struct { + kernelCryptoInfo + iv [TLS_CIPHER_ARIA_GCM_256_IV_SIZE]byte + key [TLS_CIPHER_ARIA_GCM_256_KEY_SIZE]byte + salt [TLS_CIPHER_ARIA_GCM_256_SALT_SIZE]byte + rec_seq [TLS_CIPHER_ARIA_GCM_256_REC_SEQ_SIZE]byte +} + +func (crypto *kernelCryptoARIA256GCM) String() string { + crypto.cipher_type = TLS_CIPHER_ARIA_GCM_256 + return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:]) +} + +func kernelCipher(kernel *Support, hc *badtls.RawHalfConn, cipherSuite uint16, isRX bool) kernelCrypto { + if !kernel.TLS { + return nil + } + + switch *hc.Version { + case tls.VersionTLS12: + if isRX && !kernel.TLS_Version13_RX { + return nil + } + + case tls.VersionTLS13: + if !kernel.TLS_Version13 { + return nil + } + + if isRX && !kernel.TLS_Version13_RX { + return nil + } + + default: + return nil + } + + var key, iv []byte + if *hc.Version == tls.VersionTLS13 { + key, iv = trafficKey(cipherSuiteTLS13ByID(cipherSuite), *hc.TrafficSecret) + /*if isRX { + key, iv = trafficKey(cipherSuiteTLS13ByID(cipherSuite), keyLog.RemoteTrafficSecret) + } else { + key, iv = trafficKey(cipherSuiteTLS13ByID(cipherSuite), keyLog.TrafficSecret) + }*/ + } else { + // csPtr := cipherSuiteByID(cipherSuite) + // keysFromMasterSecret(*hc.Version, csPtr, keyLog.Secret, keyLog.Random) + return nil + } + + switch cipherSuite { + case tls.TLS_AES_128_GCM_SHA256, tls.TLS_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: + crypto := new(kernelCryptoAES128GCM) + + crypto.version = *hc.Version + copy(crypto.key[:], key) + copy(crypto.iv[:], iv[4:]) + copy(crypto.salt[:], iv[:4]) + crypto.rec_seq = *hc.Seq + + return crypto + case tls.TLS_AES_256_GCM_SHA384, tls.TLS_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: + if !kernel.TLS_AES_256_GCM { + return nil + } + + crypto := new(kernelCryptoAES256GCM) + + crypto.version = *hc.Version + copy(crypto.key[:], key) + copy(crypto.iv[:], iv[4:]) + copy(crypto.salt[:], iv[:4]) + crypto.rec_seq = *hc.Seq + + return crypto + //case tls.TLS_AES_128_CCM_SHA256, tls.TLS_RSA_WITH_AES_128_CCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CCM_SHA256: + // if !kernel.TLS_AES_128_CCM { + // return nil + // } + // + // crypto := new(kernelCryptoAES128CCM) + // + // crypto.version = *hc.Version + // copy(crypto.key[:], key) + // copy(crypto.iv[:], iv[4:]) + // copy(crypto.salt[:], iv[:4]) + // crypto.rec_seq = *hc.Seq + // + // return crypto + case tls.TLS_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: + if !kernel.TLS_CHACHA20_POLY1305 { + return nil + } + + crypto := new(kernelCryptoChacha20Poly1035) + + crypto.version = *hc.Version + copy(crypto.key[:], key) + copy(crypto.iv[:], iv) + crypto.rec_seq = *hc.Seq + + return crypto + //case tls.TLS_RSA_WITH_ARIA_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256: + // if !kernel.TLS_ARIA_GCM { + // return nil + // } + // + // crypto := new(kernelCryptoARIA128GCM) + // + // crypto.version = *hc.Version + // copy(crypto.key[:], key) + // copy(crypto.iv[:], iv[4:]) + // copy(crypto.salt[:], iv[:4]) + // crypto.rec_seq = *hc.Seq + // + // return crypto + //case tls.TLS_RSA_WITH_ARIA_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384: + // if !kernel.TLS_ARIA_GCM { + // return nil + // } + // + // crypto := new(kernelCryptoARIA256GCM) + // + // crypto.version = *hc.Version + // copy(crypto.key[:], key) + // copy(crypto.iv[:], iv[4:]) + // copy(crypto.salt[:], iv[:4]) + // crypto.rec_seq = *hc.Seq + // + // return crypto + default: + return nil + } +} diff --git a/common/ktls/ktls_close.go b/common/ktls/ktls_close.go new file mode 100644 index 00000000..2052524d --- /dev/null +++ b/common/ktls/ktls_close.go @@ -0,0 +1,67 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "fmt" + "net" + "time" +) + +func (c *Conn) Close() error { + if !c.kernelTx { + return c.Conn.Close() + } + + // Interlock with Conn.Write above. + var x int32 + for { + x = c.rawConn.ActiveCall.Load() + if x&1 != 0 { + return net.ErrClosed + } + if c.rawConn.ActiveCall.CompareAndSwap(x, x|1) { + break + } + } + if x != 0 { + // io.Writer and io.Closer should not be used concurrently. + // If Close is called while a Write is currently in-flight, + // interpret that as a sign that this Close is really just + // being used to break the Write and/or clean up resources and + // avoid sending the alertCloseNotify, which may block + // waiting on handshakeMutex or the c.out mutex. + return c.conn.Close() + } + + var alertErr error + if c.rawConn.IsHandshakeComplete.Load() { + if err := c.closeNotify(); err != nil { + alertErr = fmt.Errorf("tls: failed to send closeNotify alert (but connection was closed anyway): %w", err) + } + } + + if err := c.conn.Close(); err != nil { + return err + } + return alertErr +} + +func (c *Conn) closeNotify() error { + c.rawConn.Out.Lock() + defer c.rawConn.Out.Unlock() + + if !*c.rawConn.CloseNotifySent { + // Set a Write Deadline to prevent possibly blocking forever. + c.SetWriteDeadline(time.Now().Add(time.Second * 5)) + *c.rawConn.CloseNotifyErr = c.sendAlertLocked(alertCloseNotify) + *c.rawConn.CloseNotifySent = true + // Any subsequent writes will fail. + c.SetWriteDeadline(time.Now()) + } + return *c.rawConn.CloseNotifyErr +} diff --git a/common/ktls/ktls_const.go b/common/ktls/ktls_const.go new file mode 100644 index 00000000..40cff760 --- /dev/null +++ b/common/ktls/ktls_const.go @@ -0,0 +1,24 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +const ( + maxPlaintext = 16384 // maximum plaintext payload length + maxCiphertext = 16384 + 2048 // maximum ciphertext payload length + maxCiphertextTLS13 = 16384 + 256 // maximum ciphertext length in TLS 1.3 + recordHeaderLen = 5 // record header length + maxHandshake = 65536 // maximum handshake we support (protocol max is 16 MB) + maxHandshakeCertificateMsg = 262144 // maximum certificate message size (256 KiB) + maxUselessRecords = 16 // maximum number of consecutive non-advancing records +) + +const ( + recordTypeChangeCipherSpec = 20 + recordTypeAlert = 21 + recordTypeHandshake = 22 + recordTypeApplicationData = 23 +) diff --git a/common/ktls/ktls_handshake_messages.go b/common/ktls/ktls_handshake_messages.go new file mode 100644 index 00000000..f44958c0 --- /dev/null +++ b/common/ktls/ktls_handshake_messages.go @@ -0,0 +1,238 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "fmt" + + "golang.org/x/crypto/cryptobyte" +) + +// The marshalingFunction type is an adapter to allow the use of ordinary +// functions as cryptobyte.MarshalingValue. +type marshalingFunction func(b *cryptobyte.Builder) error + +func (f marshalingFunction) Marshal(b *cryptobyte.Builder) error { + return f(b) +} + +// addBytesWithLength appends a sequence of bytes to the cryptobyte.Builder. If +// the length of the sequence is not the value specified, it produces an error. +func addBytesWithLength(b *cryptobyte.Builder, v []byte, n int) { + b.AddValue(marshalingFunction(func(b *cryptobyte.Builder) error { + if len(v) != n { + return fmt.Errorf("invalid value length: expected %d, got %d", n, len(v)) + } + b.AddBytes(v) + return nil + })) +} + +// addUint64 appends a big-endian, 64-bit value to the cryptobyte.Builder. +func addUint64(b *cryptobyte.Builder, v uint64) { + b.AddUint32(uint32(v >> 32)) + b.AddUint32(uint32(v)) +} + +// readUint64 decodes a big-endian, 64-bit value into out and advances over it. +// It reports whether the read was successful. +func readUint64(s *cryptobyte.String, out *uint64) bool { + var hi, lo uint32 + if !s.ReadUint32(&hi) || !s.ReadUint32(&lo) { + return false + } + *out = uint64(hi)<<32 | uint64(lo) + return true +} + +// readUint8LengthPrefixed acts like s.ReadUint8LengthPrefixed, but targets a +// []byte instead of a cryptobyte.String. +func readUint8LengthPrefixed(s *cryptobyte.String, out *[]byte) bool { + return s.ReadUint8LengthPrefixed((*cryptobyte.String)(out)) +} + +// readUint16LengthPrefixed acts like s.ReadUint16LengthPrefixed, but targets a +// []byte instead of a cryptobyte.String. +func readUint16LengthPrefixed(s *cryptobyte.String, out *[]byte) bool { + return s.ReadUint16LengthPrefixed((*cryptobyte.String)(out)) +} + +// readUint24LengthPrefixed acts like s.ReadUint24LengthPrefixed, but targets a +// []byte instead of a cryptobyte.String. +func readUint24LengthPrefixed(s *cryptobyte.String, out *[]byte) bool { + return s.ReadUint24LengthPrefixed((*cryptobyte.String)(out)) +} + +type keyUpdateMsg struct { + updateRequested bool +} + +func (m *keyUpdateMsg) marshal() ([]byte, error) { + var b cryptobyte.Builder + b.AddUint8(typeKeyUpdate) + b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) { + if m.updateRequested { + b.AddUint8(1) + } else { + b.AddUint8(0) + } + }) + + return b.Bytes() +} + +func (m *keyUpdateMsg) unmarshal(data []byte) bool { + s := cryptobyte.String(data) + + var updateRequested uint8 + if !s.Skip(4) || // message type and uint24 length field + !s.ReadUint8(&updateRequested) || !s.Empty() { + return false + } + switch updateRequested { + case 0: + m.updateRequested = false + case 1: + m.updateRequested = true + default: + return false + } + return true +} + +// TLS handshake message types. +const ( + typeHelloRequest uint8 = 0 + typeClientHello uint8 = 1 + typeServerHello uint8 = 2 + typeNewSessionTicket uint8 = 4 + typeEndOfEarlyData uint8 = 5 + typeEncryptedExtensions uint8 = 8 + typeCertificate uint8 = 11 + typeServerKeyExchange uint8 = 12 + typeCertificateRequest uint8 = 13 + typeServerHelloDone uint8 = 14 + typeCertificateVerify uint8 = 15 + typeClientKeyExchange uint8 = 16 + typeFinished uint8 = 20 + typeCertificateStatus uint8 = 22 + typeKeyUpdate uint8 = 24 + typeCompressedCertificate uint8 = 25 + typeMessageHash uint8 = 254 // synthetic message +) + +// TLS compression types. +const ( + compressionNone uint8 = 0 +) + +// TLS extension numbers +const ( + extensionServerName uint16 = 0 + extensionStatusRequest uint16 = 5 + extensionSupportedCurves uint16 = 10 // supported_groups in TLS 1.3, see RFC 8446, Section 4.2.7 + extensionSupportedPoints uint16 = 11 + extensionSignatureAlgorithms uint16 = 13 + extensionALPN uint16 = 16 + extensionSCT uint16 = 18 + extensionPadding uint16 = 21 + extensionExtendedMasterSecret uint16 = 23 + extensionCompressCertificate uint16 = 27 // compress_certificate in TLS 1.3 + extensionSessionTicket uint16 = 35 + extensionPreSharedKey uint16 = 41 + extensionEarlyData uint16 = 42 + extensionSupportedVersions uint16 = 43 + extensionCookie uint16 = 44 + extensionPSKModes uint16 = 45 + extensionCertificateAuthorities uint16 = 47 + extensionSignatureAlgorithmsCert uint16 = 50 + extensionKeyShare uint16 = 51 + extensionQUICTransportParameters uint16 = 57 + extensionALPS uint16 = 17513 + extensionRenegotiationInfo uint16 = 0xff01 + extensionECHOuterExtensions uint16 = 0xfd00 + extensionEncryptedClientHello uint16 = 0xfe0d +) + +type handshakeMessage interface { + marshal() ([]byte, error) + unmarshal([]byte) bool +} +type newSessionTicketMsgTLS13 struct { + lifetime uint32 + ageAdd uint32 + nonce []byte + label []byte + maxEarlyData uint32 +} + +func (m *newSessionTicketMsgTLS13) marshal() ([]byte, error) { + var b cryptobyte.Builder + b.AddUint8(typeNewSessionTicket) + b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddUint32(m.lifetime) + b.AddUint32(m.ageAdd) + b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(m.nonce) + }) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(m.label) + }) + + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + if m.maxEarlyData > 0 { + b.AddUint16(extensionEarlyData) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddUint32(m.maxEarlyData) + }) + } + }) + }) + + return b.Bytes() +} + +func (m *newSessionTicketMsgTLS13) unmarshal(data []byte) bool { + *m = newSessionTicketMsgTLS13{} + s := cryptobyte.String(data) + + var extensions cryptobyte.String + if !s.Skip(4) || // message type and uint24 length field + !s.ReadUint32(&m.lifetime) || + !s.ReadUint32(&m.ageAdd) || + !readUint8LengthPrefixed(&s, &m.nonce) || + !readUint16LengthPrefixed(&s, &m.label) || + !s.ReadUint16LengthPrefixed(&extensions) || + !s.Empty() { + return false + } + + for !extensions.Empty() { + var extension uint16 + var extData cryptobyte.String + if !extensions.ReadUint16(&extension) || + !extensions.ReadUint16LengthPrefixed(&extData) { + return false + } + + switch extension { + case extensionEarlyData: + if !extData.ReadUint32(&m.maxEarlyData) { + return false + } + default: + // Ignore unknown extensions. + continue + } + + if !extData.Empty() { + return false + } + } + + return true +} diff --git a/common/ktls/ktls_key_update.go b/common/ktls/ktls_key_update.go new file mode 100644 index 00000000..35268e8f --- /dev/null +++ b/common/ktls/ktls_key_update.go @@ -0,0 +1,173 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "os" +) + +// handlePostHandshakeMessage processes a handshake message arrived after the +// handshake is complete. Up to TLS 1.2, it indicates the start of a renegotiation. +func (c *Conn) handlePostHandshakeMessage() error { + if *c.rawConn.Vers != tls.VersionTLS13 { + return errors.New("ktls: kernel does not support TLS 1.2 renegotiation") + } + + msg, err := c.readHandshake(nil) + if err != nil { + return err + } + //c.retryCount++ + //if c.retryCount > maxUselessRecords { + // c.sendAlert(alertUnexpectedMessage) + // return c.in.setErrorLocked(errors.New("tls: too many non-advancing records")) + //} + + switch msg := msg.(type) { + case *newSessionTicketMsgTLS13: + // return errors.New("ktls: received new session ticket") + return nil + case *keyUpdateMsg: + return c.handleKeyUpdate(msg) + } + // The QUIC layer is supposed to treat an unexpected post-handshake CertificateRequest + // as a QUIC-level PROTOCOL_VIOLATION error (RFC 9001, Section 4.4). Returning an + // unexpected_message alert here doesn't provide it with enough information to distinguish + // this condition from other unexpected messages. This is probably fine. + c.sendAlert(alertUnexpectedMessage) + return fmt.Errorf("tls: received unexpected handshake message of type %T", msg) +} + +func (c *Conn) handleKeyUpdate(keyUpdate *keyUpdateMsg) error { + //if c.quic != nil { + // c.sendAlert(alertUnexpectedMessage) + // return c.in.setErrorLocked(errors.New("tls: received unexpected key update message")) + //} + + cipherSuite := cipherSuiteTLS13ByID(*c.rawConn.CipherSuite) + if cipherSuite == nil { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertInternalError)) + } + + newSecret := nextTrafficSecret(cipherSuite, *c.rawConn.In.TrafficSecret) + c.rawConn.In.SetTrafficSecret(cipherSuite, 0 /*tls.QUICEncryptionLevelInitial*/, newSecret) + + err := c.resetupRX() + if err != nil { + c.sendAlert(alertInternalError) + return c.rawConn.In.SetErrorLocked(fmt.Errorf("ktls: resetupRX failed: %w", err)) + } + + if keyUpdate.updateRequested { + c.rawConn.Out.Lock() + defer c.rawConn.Out.Unlock() + + resetup, err := c.resetupTX() + if err != nil { + c.sendAlertLocked(alertInternalError) + return c.rawConn.Out.SetErrorLocked(fmt.Errorf("ktls: resetupTX failed: %w", err)) + } + + msg := &keyUpdateMsg{} + msgBytes, err := msg.marshal() + if err != nil { + return err + } + _, err = c.writeRecordLocked(recordTypeHandshake, msgBytes) + if err != nil { + // Surface the error at the next write. + c.rawConn.Out.SetErrorLocked(err) + return nil + } + + newSecret := nextTrafficSecret(cipherSuite, *c.rawConn.Out.TrafficSecret) + c.rawConn.Out.SetTrafficSecret(cipherSuite, 0 /*QUICEncryptionLevelInitial*/, newSecret) + + err = resetup() + if err != nil { + return c.rawConn.Out.SetErrorLocked(fmt.Errorf("ktls: resetupTX failed: %w", err)) + } + } + + return nil +} + +func (c *Conn) readHandshakeBytes(n int) error { + //if c.quic != nil { + // return c.quicReadHandshakeBytes(n) + //} + for c.rawConn.Hand.Len() < n { + if err := c.readRecord(); err != nil { + return err + } + } + return nil +} + +func (c *Conn) readHandshake(transcript io.Writer) (any, error) { + if err := c.readHandshakeBytes(4); err != nil { + return nil, err + } + data := c.rawConn.Hand.Bytes() + + maxHandshakeSize := maxHandshake + // hasVers indicates we're past the first message, forcing someone trying to + // make us just allocate a large buffer to at least do the initial part of + // the handshake first. + //if c.haveVers && data[0] == typeCertificate { + // Since certificate messages are likely to be the only messages that + // can be larger than maxHandshake, we use a special limit for just + // those messages. + //maxHandshakeSize = maxHandshakeCertificateMsg + //} + + n := int(data[1])<<16 | int(data[2])<<8 | int(data[3]) + if n > maxHandshakeSize { + c.sendAlertLocked(alertInternalError) + return nil, c.rawConn.In.SetErrorLocked(fmt.Errorf("tls: handshake message of length %d bytes exceeds maximum of %d bytes", n, maxHandshakeSize)) + } + if err := c.readHandshakeBytes(4 + n); err != nil { + return nil, err + } + data = c.rawConn.Hand.Next(4 + n) + return c.unmarshalHandshakeMessage(data, transcript) +} + +func (c *Conn) unmarshalHandshakeMessage(data []byte, transcript io.Writer) (any, error) { + var m handshakeMessage + switch data[0] { + case typeNewSessionTicket: + if *c.rawConn.Vers == tls.VersionTLS13 { + m = new(newSessionTicketMsgTLS13) + } else { + return nil, os.ErrInvalid + } + case typeKeyUpdate: + m = new(keyUpdateMsg) + default: + return nil, c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + + // The handshake message unmarshalers + // expect to be able to keep references to data, + // so pass in a fresh copy that won't be overwritten. + data = append([]byte(nil), data...) + + if !m.unmarshal(data) { + return nil, c.rawConn.In.SetErrorLocked(c.sendAlert(alertDecodeError)) + } + + if transcript != nil { + transcript.Write(data) + } + + return m, nil +} diff --git a/common/ktls/ktls_linux.go b/common/ktls/ktls_linux.go new file mode 100644 index 00000000..1e327751 --- /dev/null +++ b/common/ktls/ktls_linux.go @@ -0,0 +1,329 @@ +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "crypto/tls" + "errors" + "io" + "os" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/sagernet/sing-box/common/badversion" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/shell" + + "golang.org/x/sys/unix" +) + +// mod from https://gitlab.com/go-extension/tls + +const ( + TLS_TX = 1 + TLS_RX = 2 + TLS_TX_ZEROCOPY_RO = 3 // TX zerocopy (only sendfile now) + TLS_RX_EXPECT_NO_PAD = 4 // Attempt opportunistic zero-copy, TLS 1.3 only + + TLS_SET_RECORD_TYPE = 1 + TLS_GET_RECORD_TYPE = 2 +) + +type Support struct { + TLS, TLS_RX bool + TLS_Version13, TLS_Version13_RX bool + + TLS_TX_ZEROCOPY bool + TLS_RX_NOPADDING bool + + TLS_AES_256_GCM bool + TLS_AES_128_CCM bool + TLS_CHACHA20_POLY1305 bool + TLS_SM4 bool + TLS_ARIA_GCM bool + + TLS_Version13_KeyUpdate bool +} + +var KernelSupport = sync.OnceValues(func() (*Support, error) { + var uname unix.Utsname + err := unix.Uname(&uname) + if err != nil { + return nil, err + } + + kernelVersion := badversion.Parse(strings.Trim(string(uname.Release[:]), "\x00")) + if err != nil { + return nil, err + } + var support Support + switch { + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 6, Minor: 14}): + support.TLS_Version13_KeyUpdate = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 6, Minor: 1}): + support.TLS_ARIA_GCM = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 6}): + support.TLS_Version13_RX = true + support.TLS_RX_NOPADDING = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 5, Minor: 19}): + support.TLS_TX_ZEROCOPY = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 5, Minor: 16}): + support.TLS_SM4 = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 5, Minor: 11}): + support.TLS_CHACHA20_POLY1305 = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 5, Minor: 2}): + support.TLS_AES_128_CCM = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 5, Minor: 1}): + support.TLS_AES_256_GCM = true + support.TLS_Version13 = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 4, Minor: 17}): + support.TLS_RX = true + fallthrough + case kernelVersion.GreaterThanOrEqual(badversion.Version{Major: 4, Minor: 13}): + support.TLS = true + } + + if support.TLS && support.TLS_Version13 { + _, err := os.Stat("/sys/module/tls") + if err != nil { + if os.Getuid() == 0 { + output, err := shell.Exec("modprobe", "tls").Read() + if err != nil { + return nil, E.Extend(E.Cause(err, "modprobe tls"), output) + } + } else { + return nil, E.New("ktls: kernel TLS module not loaded") + } + } + } + + return &support, nil +}) + +func Load() error { + support, err := KernelSupport() + if err != nil { + return E.Cause(err, "ktls: check availability") + } + if !support.TLS || !support.TLS_Version13 { + return E.New("ktls: kernel does not support TLS 1.3") + } + return nil +} + +func (c *Conn) setupKernel(txOffload, rxOffload bool) error { + if !txOffload && !rxOffload { + return os.ErrInvalid + } + support, err := KernelSupport() + if err != nil { + return E.Cause(err, "check availability") + } + if !support.TLS || !support.TLS_Version13 { + return E.New("kernel does not support TLS 1.3") + } + c.rawConn.Out.Lock() + defer c.rawConn.Out.Unlock() + err = control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptString(int(fd), unix.SOL_TCP, unix.TCP_ULP, "tls") + }) + if err != nil { + return os.NewSyscallError("setsockopt", err) + } + + if txOffload { + txCrypto := kernelCipher(support, c.rawConn.Out, *c.rawConn.CipherSuite, false) + if txCrypto == nil { + return E.New("unsupported cipher suite") + } + err = control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptString(int(fd), unix.SOL_TLS, TLS_TX, txCrypto.String()) + }) + if err != nil { + return err + } + if support.TLS_TX_ZEROCOPY { + err = control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptInt(int(fd), unix.SOL_TLS, TLS_TX_ZEROCOPY_RO, 1) + }) + if err != nil { + return err + } + } + c.kernelTx = true + c.logger.DebugContext(c.ctx, "ktls: kernel TLS TX enabled") + } + + if rxOffload { + rxCrypto := kernelCipher(support, c.rawConn.In, *c.rawConn.CipherSuite, true) + if rxCrypto == nil { + return E.New("unsupported cipher suite") + } + err = control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptString(int(fd), unix.SOL_TLS, TLS_RX, rxCrypto.String()) + }) + if err != nil { + return err + } + if *c.rawConn.Vers >= tls.VersionTLS13 && support.TLS_RX_NOPADDING { + err = control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptInt(int(fd), unix.SOL_TLS, TLS_RX_EXPECT_NO_PAD, 1) + }) + if err != nil { + return err + } + } + c.kernelRx = true + c.logger.DebugContext(c.ctx, "ktls: kernel TLS RX enabled") + } + return nil +} + +func (c *Conn) resetupTX() (func() error, error) { + if !c.kernelTx { + return nil, nil + } + support, err := KernelSupport() + if err != nil { + return nil, err + } + if !support.TLS_Version13_KeyUpdate { + return nil, errors.New("ktls: kernel does not support rekey") + } + txCrypto := kernelCipher(support, c.rawConn.Out, *c.rawConn.CipherSuite, false) + if txCrypto == nil { + return nil, errors.New("ktls: set kernelCipher on unsupported tls session") + } + return func() error { + return control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptString(int(fd), unix.SOL_TLS, TLS_TX, txCrypto.String()) + }) + }, nil +} + +func (c *Conn) resetupRX() error { + if !c.kernelRx { + return nil + } + support, err := KernelSupport() + if err != nil { + return err + } + if !support.TLS_Version13_KeyUpdate { + return errors.New("ktls: kernel does not support rekey") + } + rxCrypto := kernelCipher(support, c.rawConn.In, *c.rawConn.CipherSuite, true) + if rxCrypto == nil { + return errors.New("ktls: set kernelCipher on unsupported tls session") + } + return control.Raw(c.rawSyscallConn, func(fd uintptr) error { + return syscall.SetsockoptString(int(fd), unix.SOL_TLS, TLS_RX, rxCrypto.String()) + }) +} + +func (c *Conn) readKernelRecord() (uint8, []byte, error) { + if c.rawConn.RawInput.Len() < maxPlaintext { + c.rawConn.RawInput.Grow(maxPlaintext - c.rawConn.RawInput.Len()) + } + + data := c.rawConn.RawInput.Bytes()[:maxPlaintext] + + // cmsg for record type + buffer := make([]byte, unix.CmsgSpace(1)) + cmsg := (*unix.Cmsghdr)(unsafe.Pointer(&buffer[0])) + cmsg.SetLen(unix.CmsgLen(1)) + + var iov unix.Iovec + iov.Base = &data[0] + iov.SetLen(len(data)) + + var msg unix.Msghdr + msg.Control = &buffer[0] + msg.Controllen = cmsg.Len + msg.Iov = &iov + msg.Iovlen = 1 + + var n int + var err error + er := c.rawSyscallConn.Read(func(fd uintptr) bool { + n, err = recvmsg(int(fd), &msg, 0) + return err != unix.EAGAIN || c.pendingRxSplice + }) + if er != nil { + return 0, nil, er + } + switch err { + case nil: + case syscall.EINVAL, syscall.EAGAIN: + return 0, nil, c.rawConn.In.SetErrorLocked(c.sendAlert(alertProtocolVersion)) + case syscall.EMSGSIZE: + return 0, nil, c.rawConn.In.SetErrorLocked(c.sendAlert(alertRecordOverflow)) + case syscall.EBADMSG: + return 0, nil, c.rawConn.In.SetErrorLocked(c.sendAlert(alertDecryptError)) + default: + return 0, nil, err + } + + if n <= 0 { + return 0, nil, c.rawConn.In.SetErrorLocked(io.EOF) + } + + if cmsg.Level == unix.SOL_TLS && cmsg.Type == TLS_GET_RECORD_TYPE { + typ := buffer[unix.CmsgLen(0)] + return typ, data[:n], nil + } + + return recordTypeApplicationData, data[:n], nil +} + +func (c *Conn) writeKernelRecord(typ uint16, data []byte) (int, error) { + if typ == recordTypeApplicationData { + return c.conn.Write(data) + } + + // cmsg for record type + buffer := make([]byte, unix.CmsgSpace(1)) + cmsg := (*unix.Cmsghdr)(unsafe.Pointer(&buffer[0])) + cmsg.SetLen(unix.CmsgLen(1)) + buffer[unix.CmsgLen(0)] = byte(typ) + cmsg.Level = unix.SOL_TLS + cmsg.Type = TLS_SET_RECORD_TYPE + + var iov unix.Iovec + iov.Base = &data[0] + iov.SetLen(len(data)) + + var msg unix.Msghdr + msg.Control = &buffer[0] + msg.Controllen = cmsg.Len + msg.Iov = &iov + msg.Iovlen = 1 + + var n int + var err error + ew := c.rawSyscallConn.Write(func(fd uintptr) bool { + n, err = sendmsg(int(fd), &msg, 0) + return err != unix.EAGAIN + }) + if ew != nil { + return 0, ew + } + return n, err +} + +//go:linkname recvmsg golang.org/x/sys/unix.recvmsg +func recvmsg(fd int, msg *unix.Msghdr, flags int) (n int, err error) + +//go:linkname sendmsg golang.org/x/sys/unix.sendmsg +func sendmsg(fd int, msg *unix.Msghdr, flags int) (n int, err error) diff --git a/common/ktls/ktls_prf.go b/common/ktls/ktls_prf.go new file mode 100644 index 00000000..ecf0b735 --- /dev/null +++ b/common/ktls/ktls_prf.go @@ -0,0 +1,24 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import "unsafe" + +//go:linkname cipherSuiteByID github.com/metacubex/utls.cipherSuiteByID +func cipherSuiteByID(id uint16) unsafe.Pointer + +//go:linkname keysFromMasterSecret github.com/metacubex/utls.keysFromMasterSecret +func keysFromMasterSecret(version uint16, suite unsafe.Pointer, masterSecret, clientRandom, serverRandom []byte, macLen, keyLen, ivLen int) (clientMAC, serverMAC, clientKey, serverKey, clientIV, serverIV []byte) + +//go:linkname cipherSuiteTLS13ByID github.com/metacubex/utls.cipherSuiteTLS13ByID +func cipherSuiteTLS13ByID(id uint16) unsafe.Pointer + +//go:linkname nextTrafficSecret github.com/metacubex/utls.(*cipherSuiteTLS13).nextTrafficSecret +func nextTrafficSecret(cs unsafe.Pointer, trafficSecret []byte) []byte + +//go:linkname trafficKey github.com/metacubex/utls.(*cipherSuiteTLS13).trafficKey +func trafficKey(cs unsafe.Pointer, trafficSecret []byte) (key, iv []byte) diff --git a/common/ktls/ktls_read.go b/common/ktls/ktls_read.go new file mode 100644 index 00000000..5609bfb5 --- /dev/null +++ b/common/ktls/ktls_read.go @@ -0,0 +1,293 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net" + "unsafe" +) + +func (c *Conn) Read(b []byte) (int, error) { + if !c.kernelRx { + return c.Conn.Read(b) + } + + if len(b) == 0 { + // Put this after Handshake, in case people were calling + // Read(nil) for the side effect of the Handshake. + return 0, nil + } + + c.rawConn.In.Lock() + defer c.rawConn.In.Unlock() + + for c.rawConn.Input.Len() == 0 { + if err := c.readRecord(); err != nil { + return 0, err + } + for c.rawConn.Hand.Len() > 0 { + if err := c.handlePostHandshakeMessage(); err != nil { + return 0, err + } + } + } + + n, _ := c.rawConn.Input.Read(b) + + // If a close-notify alert is waiting, read it so that we can return (n, + // EOF) instead of (n, nil), to signal to the HTTP response reading + // goroutine that the connection is now closed. This eliminates a race + // where the HTTP response reading goroutine would otherwise not observe + // the EOF until its next read, by which time a client goroutine might + // have already tried to reuse the HTTP connection for a new request. + // See https://golang.org/cl/76400046 and https://golang.org/issue/3514 + if n != 0 && c.rawConn.Input.Len() == 0 && c.rawConn.RawInput.Len() > 0 && + c.rawConn.RawInput.Bytes()[0] == recordTypeAlert { + if err := c.readRecord(); err != nil { + return n, err // will be io.EOF on closeNotify + } + } + + return n, nil +} + +func (c *Conn) readRecord() error { + if *c.rawConn.In.Err != nil { + return *c.rawConn.In.Err + } + + typ, data, err := c.readRawRecord() + if err != nil { + return err + } + + if len(data) > maxPlaintext { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertRecordOverflow)) + } + + // Application Data messages are always protected. + if c.rawConn.In.Cipher == nil && typ == recordTypeApplicationData { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + + //if typ != recordTypeAlert && typ != recordTypeChangeCipherSpec && len(data) > 0 { + // This is a state-advancing message: reset the retry count. + // c.retryCount = 0 + //} + + // Handshake messages MUST NOT be interleaved with other record types in TLS 1.3. + if *c.rawConn.Vers == tls.VersionTLS13 && typ != recordTypeHandshake && c.rawConn.Hand.Len() > 0 { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + + switch typ { + default: + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + case recordTypeAlert: + //if c.quic != nil { + // return c.rawConn.In.setErrorLocked(c.sendAlert(alertUnexpectedMessage)) + //} + if len(data) != 2 { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + if data[1] == alertCloseNotify { + return c.rawConn.In.SetErrorLocked(io.EOF) + } + if *c.rawConn.Vers == tls.VersionTLS13 { + // TLS 1.3 removed warning-level alerts except for alertUserCanceled + // (RFC 8446, § 6.1). Since at least one major implementation + // (https://bugs.openjdk.org/browse/JDK-8323517) misuses this alert, + // many TLS stacks now ignore it outright when seen in a TLS 1.3 + // handshake (e.g. BoringSSL, NSS, Rustls). + if data[1] == alertUserCanceled { + // Like TLS 1.2 alertLevelWarning alerts, we drop the record and retry. + return c.retryReadRecord( /*expectChangeCipherSpec*/ ) + } + return c.rawConn.In.SetErrorLocked(&net.OpError{Op: "remote error", Err: tls.AlertError(data[1])}) + } + switch data[0] { + case alertLevelWarning: + // Drop the record on the floor and retry. + return c.retryReadRecord( /*expectChangeCipherSpec*/ ) + case alertLevelError: + return c.rawConn.In.SetErrorLocked(&net.OpError{Op: "remote error", Err: tls.AlertError(data[1])}) + default: + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + + case recordTypeChangeCipherSpec: + if len(data) != 1 || data[0] != 1 { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertDecodeError)) + } + // Handshake messages are not allowed to fragment across the CCS. + if c.rawConn.Hand.Len() > 0 { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + // In TLS 1.3, change_cipher_spec records are ignored until the + // Finished. See RFC 8446, Appendix D.4. Note that according to Section + // 5, a server can send a ChangeCipherSpec before its ServerHello, when + // c.vers is still unset. That's not useful though and suspicious if the + // server then selects a lower protocol version, so don't allow that. + if *c.rawConn.Vers == tls.VersionTLS13 { + return c.retryReadRecord( /*expectChangeCipherSpec*/ ) + } + // if !expectChangeCipherSpec { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + //} + //if err := c.rawConn.In.changeCipherSpec(); err != nil { + // return c.rawConn.In.setErrorLocked(c.sendAlert(err.(alert))) + //} + + case recordTypeApplicationData: + // Some OpenSSL servers send empty records in order to randomize the + // CBC RawIV. Ignore a limited number of empty records. + if len(data) == 0 { + return c.retryReadRecord( /*expectChangeCipherSpec*/ ) + } + // Note that data is owned by c.rawInput, following the Next call above, + // to avoid copying the plaintext. This is safe because c.rawInput is + // not read from or written to until c.input is drained. + c.rawConn.Input.Reset(data) + case recordTypeHandshake: + if len(data) == 0 { + return c.rawConn.In.SetErrorLocked(c.sendAlert(alertUnexpectedMessage)) + } + c.rawConn.Hand.Write(data) + } + + return nil +} + +//nolint:staticcheck +func (c *Conn) readRawRecord() (typ uint8, data []byte, err error) { + // Read from kernel. + if c.kernelRx { + return c.readKernelRecord() + } + + // Read header, payload. + if err = c.readFromUntil(c.conn, recordHeaderLen); err != nil { + // RFC 8446, Section 6.1 suggests that EOF without an alertCloseNotify + // is an error, but popular web sites seem to do this, so we accept it + // if and only if at the record boundary. + if err == io.ErrUnexpectedEOF && c.rawConn.RawInput.Len() == 0 { + err = io.EOF + } + if e, ok := err.(net.Error); !ok || !e.Temporary() { + c.rawConn.In.SetErrorLocked(err) + } + return + } + hdr := c.rawConn.RawInput.Bytes()[:recordHeaderLen] + typ = hdr[0] + + vers := uint16(hdr[1])<<8 | uint16(hdr[2]) + expectedVers := *c.rawConn.Vers + if expectedVers == tls.VersionTLS13 { + // All TLS 1.3 records are expected to have 0x0303 (1.2) after + // the initial hello (RFC 8446 Section 5.1). + expectedVers = tls.VersionTLS12 + } + n := int(hdr[3])<<8 | int(hdr[4]) + if /*c.haveVers && */ vers != expectedVers { + c.sendAlert(alertProtocolVersion) + msg := fmt.Sprintf("received record with version %x when expecting version %x", vers, expectedVers) + err = c.rawConn.In.SetErrorLocked(c.newRecordHeaderError(nil, msg)) + return + } + //if !c.haveVers { + // // First message, be extra suspicious: this might not be a TLS + // // client. Bail out before reading a full 'body', if possible. + // // The current max version is 3.3 so if the version is >= 16.0, + // // it's probably not real. + // if (typ != recordTypeAlert && typ != recordTypeHandshake) || vers >= 0x1000 { + // err = c.rawConn.In.SetErrorLocked(c.newRecordHeaderError(c.conn, "first record does not look like a TLS handshake")) + // return + // } + //} + if *c.rawConn.Vers == tls.VersionTLS13 && n > maxCiphertextTLS13 || n > maxCiphertext { + c.sendAlert(alertRecordOverflow) + msg := fmt.Sprintf("oversized record received with length %d", n) + err = c.rawConn.In.SetErrorLocked(c.newRecordHeaderError(nil, msg)) + return + } + if err = c.readFromUntil(c.conn, recordHeaderLen+n); err != nil { + if e, ok := err.(net.Error); !ok || !e.Temporary() { + c.rawConn.In.SetErrorLocked(err) + } + return + } + + // Process message. + record := c.rawConn.RawInput.Next(recordHeaderLen + n) + data, typ, err = c.rawConn.In.Decrypt(record) + if err != nil { + err = c.rawConn.In.SetErrorLocked(c.sendAlert(*(*uint8)((*[2]unsafe.Pointer)(unsafe.Pointer(&err))[1]))) + return + } + return +} + +// retryReadRecord recurs into readRecordOrCCS to drop a non-advancing record, like +// a warning alert, empty application_data, or a change_cipher_spec in TLS 1.3. +func (c *Conn) retryReadRecord( /*expectChangeCipherSpec bool*/ ) error { + //c.retryCount++ + //if c.retryCount > maxUselessRecords { + // c.sendAlert(alertUnexpectedMessage) + // return c.in.setErrorLocked(errors.New("tls: too many ignored records")) + //} + return c.readRecord( /*expectChangeCipherSpec*/ ) +} + +// atLeastReader reads from R, stopping with EOF once at least N bytes have been +// read. It is different from an io.LimitedReader in that it doesn't cut short +// the last Read call, and in that it considers an early EOF an error. +type atLeastReader struct { + R io.Reader + N int64 +} + +func (r *atLeastReader) Read(p []byte) (int, error) { + if r.N <= 0 { + return 0, io.EOF + } + n, err := r.R.Read(p) + r.N -= int64(n) // won't underflow unless len(p) >= n > 9223372036854775809 + if r.N > 0 && err == io.EOF { + return n, io.ErrUnexpectedEOF + } + if r.N <= 0 && err == nil { + return n, io.EOF + } + return n, err +} + +// readFromUntil reads from r into c.rawConn.RawInput until c.rawConn.RawInput contains +// at least n bytes or else returns an error. +func (c *Conn) readFromUntil(r io.Reader, n int) error { + if c.rawConn.RawInput.Len() >= n { + return nil + } + needs := n - c.rawConn.RawInput.Len() + // There might be extra input waiting on the wire. Make a best effort + // attempt to fetch it so that it can be used in (*Conn).Read to + // "predict" closeNotify alerts. + c.rawConn.RawInput.Grow(needs + bytes.MinRead) + _, err := c.rawConn.RawInput.ReadFrom(&atLeastReader{r, int64(needs)}) + return err +} + +func (c *Conn) newRecordHeaderError(conn net.Conn, msg string) (err tls.RecordHeaderError) { + err.Msg = msg + err.Conn = conn + copy(err.RecordHeader[:], c.rawConn.RawInput.Bytes()) + return err +} diff --git a/common/ktls/ktls_read_wait.go b/common/ktls/ktls_read_wait.go new file mode 100644 index 00000000..4b4edc1e --- /dev/null +++ b/common/ktls/ktls_read_wait.go @@ -0,0 +1,41 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "github.com/sagernet/sing/common/buf" + N "github.com/sagernet/sing/common/network" +) + +func (c *Conn) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) { + c.readWaitOptions = options + return false +} + +func (c *Conn) WaitReadBuffer() (buffer *buf.Buffer, err error) { + c.rawConn.In.Lock() + defer c.rawConn.In.Unlock() + for c.rawConn.Input.Len() == 0 { + err = c.readRecord() + if err != nil { + return + } + } + buffer = c.readWaitOptions.NewBuffer() + n, err := c.rawConn.Input.Read(buffer.FreeBytes()) + if err != nil { + buffer.Release() + return + } + buffer.Truncate(n) + if n != 0 && c.rawConn.Input.Len() == 0 && c.rawConn.Input.Len() > 0 && + c.rawConn.RawInput.Bytes()[0] == recordTypeAlert { + _ = c.rawConn.ReadRecord() + } + c.readWaitOptions.PostReturn(buffer) + return +} diff --git a/common/ktls/ktls_stub_nolinkname.go b/common/ktls/ktls_stub_nolinkname.go new file mode 100644 index 00000000..44a0b30c --- /dev/null +++ b/common/ktls/ktls_stub_nolinkname.go @@ -0,0 +1,15 @@ +//go:build linux && go1.25 && !badlinkname + +package ktls + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + aTLS "github.com/sagernet/sing/common/tls" +) + +func NewConn(ctx context.Context, logger logger.ContextLogger, conn aTLS.Conn, txOffload, rxOffload bool) (aTLS.Conn, error) { + return nil, E.New("kTLS requires build flags `badlinkname` and `-ldflags=-checklinkname=0`, please recompile your binary") +} diff --git a/common/ktls/ktls_stub_nonlinux.go b/common/ktls/ktls_stub_nonlinux.go new file mode 100644 index 00000000..e754b775 --- /dev/null +++ b/common/ktls/ktls_stub_nonlinux.go @@ -0,0 +1,15 @@ +//go:build !linux + +package ktls + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + aTLS "github.com/sagernet/sing/common/tls" +) + +func NewConn(ctx context.Context, logger logger.ContextLogger, conn aTLS.Conn, txOffload, rxOffload bool) (aTLS.Conn, error) { + return nil, E.New("kTLS is only supported on Linux") +} diff --git a/common/ktls/ktls_stub_oldgo.go b/common/ktls/ktls_stub_oldgo.go new file mode 100644 index 00000000..613bf7f1 --- /dev/null +++ b/common/ktls/ktls_stub_oldgo.go @@ -0,0 +1,15 @@ +//go:build linux && !go1.25 + +package ktls + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + aTLS "github.com/sagernet/sing/common/tls" +) + +func NewConn(ctx context.Context, logger logger.ContextLogger, conn aTLS.Conn, txOffload, rxOffload bool) (aTLS.Conn, error) { + return nil, E.New("kTLS requires Go 1.25 or later, please recompile your binary") +} diff --git a/common/ktls/ktls_write.go b/common/ktls/ktls_write.go new file mode 100644 index 00000000..76533b4a --- /dev/null +++ b/common/ktls/ktls_write.go @@ -0,0 +1,154 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && go1.25 && badlinkname + +package ktls + +import ( + "crypto/cipher" + "crypto/tls" + "errors" + "net" +) + +func (c *Conn) Write(b []byte) (int, error) { + if !c.kernelTx { + return c.Conn.Write(b) + } + // interlock with Close below + for { + x := c.rawConn.ActiveCall.Load() + if x&1 != 0 { + return 0, net.ErrClosed + } + if c.rawConn.ActiveCall.CompareAndSwap(x, x+2) { + break + } + } + defer c.rawConn.ActiveCall.Add(-2) + + //if err := c.Conn.HandshakeContext(context.Background()); err != nil { + // return 0, err + //} + + c.rawConn.Out.Lock() + defer c.rawConn.Out.Unlock() + + if err := *c.rawConn.Out.Err; err != nil { + return 0, err + } + + if !c.rawConn.IsHandshakeComplete.Load() { + return 0, tls.AlertError(alertInternalError) + } + + if *c.rawConn.CloseNotifySent { + // return 0, errShutdown + return 0, errors.New("tls: protocol is shutdown") + } + + // TLS 1.0 is susceptible to a chosen-plaintext + // attack when using block mode ciphers due to predictable IVs. + // This can be prevented by splitting each Application Data + // record into two records, effectively randomizing the RawIV. + // + // https://www.openssl.org/~bodo/tls-cbc.txt + // https://bugzilla.mozilla.org/show_bug.cgi?id=665814 + // https://www.imperialviolet.org/2012/01/15/beastfollowup.html + + var m int + if len(b) > 1 && *c.rawConn.Vers == tls.VersionTLS10 { + if _, ok := (*c.rawConn.Out.Cipher).(cipher.BlockMode); ok { + n, err := c.writeRecordLocked(recordTypeApplicationData, b[:1]) + if err != nil { + return n, c.rawConn.Out.SetErrorLocked(err) + } + m, b = 1, b[1:] + } + } + + n, err := c.writeRecordLocked(recordTypeApplicationData, b) + return n + m, c.rawConn.Out.SetErrorLocked(err) +} + +func (c *Conn) writeRecordLocked(typ uint16, data []byte) (n int, err error) { + if !c.kernelTx { + return c.rawConn.WriteRecordLocked(typ, data) + } + /*for len(data) > 0 { + m := len(data) + if maxPayload := c.maxPayloadSizeForWrite(typ); m > maxPayload { + m = maxPayload + } + _, err = c.writeKernelRecord(typ, data[:m]) + if err != nil { + return + } + n += m + data = data[m:] + }*/ + return c.writeKernelRecord(typ, data) +} + +const ( + // tcpMSSEstimate is a conservative estimate of the TCP maximum segment + // size (MSS). A constant is used, rather than querying the kernel for + // the actual MSS, to avoid complexity. The value here is the IPv6 + // minimum MTU (1280 bytes) minus the overhead of an IPv6 header (40 + // bytes) and a TCP header with timestamps (32 bytes). + tcpMSSEstimate = 1208 + + // recordSizeBoostThreshold is the number of bytes of application data + // sent after which the TLS record size will be increased to the + // maximum. + recordSizeBoostThreshold = 128 * 1024 +) + +func (c *Conn) maxPayloadSizeForWrite(typ uint16) int { + if /*c.config.DynamicRecordSizingDisabled ||*/ typ != recordTypeApplicationData { + return maxPlaintext + } + + if *c.rawConn.PacketsSent >= recordSizeBoostThreshold { + return maxPlaintext + } + + // Subtract TLS overheads to get the maximum payload size. + payloadBytes := tcpMSSEstimate - recordHeaderLen - c.rawConn.Out.ExplicitNonceLen() + if rawCipher := *c.rawConn.Out.Cipher; rawCipher != nil { + switch ciph := rawCipher.(type) { + case cipher.Stream: + payloadBytes -= (*c.rawConn.Out.Mac).Size() + case cipher.AEAD: + payloadBytes -= ciph.Overhead() + /*case cbcMode: + blockSize := ciph.BlockSize() + // The payload must fit in a multiple of blockSize, with + // room for at least one padding byte. + payloadBytes = (payloadBytes & ^(blockSize - 1)) - 1 + // The RawMac is appended before padding so affects the + // payload size directly. + payloadBytes -= c.out.mac.Size()*/ + default: + panic("unknown cipher type") + } + } + if *c.rawConn.Vers == tls.VersionTLS13 { + payloadBytes-- // encrypted ContentType + } + + // Allow packet growth in arithmetic progression up to max. + pkt := *c.rawConn.PacketsSent + *c.rawConn.PacketsSent++ + if pkt > 1000 { + return maxPlaintext // avoid overflow in multiply below + } + + n := payloadBytes * int(pkt+1) + if n > maxPlaintext { + n = maxPlaintext + } + return n +} diff --git a/common/listener/listener.go b/common/listener/listener.go new file mode 100644 index 00000000..cc27a62e --- /dev/null +++ b/common/listener/listener.go @@ -0,0 +1,172 @@ +package listener + +import ( + "context" + "net" + "net/netip" + "runtime" + "strings" + "sync/atomic" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/settings" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/vishvananda/netns" +) + +type Listener struct { + ctx context.Context + logger logger.ContextLogger + network []string + listenOptions option.ListenOptions + connHandler adapter.ConnectionHandlerEx + packetHandler adapter.PacketHandlerEx + oobPacketHandler adapter.OOBPacketHandlerEx + threadUnsafePacketWriter bool + disablePacketOutput bool + setSystemProxy bool + systemProxySOCKS bool + tproxy bool + + tcpListener net.Listener + systemProxy settings.SystemProxy + udpConn *net.UDPConn + udpAddr M.Socksaddr + packetOutbound chan *N.PacketBuffer + packetOutboundClosed chan struct{} + shutdown atomic.Bool +} + +type Options struct { + Context context.Context + Logger logger.ContextLogger + Network []string + Listen option.ListenOptions + ConnectionHandler adapter.ConnectionHandlerEx + PacketHandler adapter.PacketHandlerEx + OOBPacketHandler adapter.OOBPacketHandlerEx + ThreadUnsafePacketWriter bool + DisablePacketOutput bool + SetSystemProxy bool + SystemProxySOCKS bool + TProxy bool +} + +func New( + options Options, +) *Listener { + return &Listener{ + ctx: options.Context, + logger: options.Logger, + network: options.Network, + listenOptions: options.Listen, + connHandler: options.ConnectionHandler, + packetHandler: options.PacketHandler, + oobPacketHandler: options.OOBPacketHandler, + threadUnsafePacketWriter: options.ThreadUnsafePacketWriter, + disablePacketOutput: options.DisablePacketOutput, + setSystemProxy: options.SetSystemProxy, + systemProxySOCKS: options.SystemProxySOCKS, + tproxy: options.TProxy, + } +} + +func (l *Listener) Start() error { + if common.Contains(l.network, N.NetworkTCP) { + _, err := l.ListenTCP() + if err != nil { + return err + } + go l.loopTCPIn() + } + if common.Contains(l.network, N.NetworkUDP) { + _, err := l.ListenUDP() + if err != nil { + return err + } + l.packetOutboundClosed = make(chan struct{}) + l.packetOutbound = make(chan *N.PacketBuffer, 64) + go l.loopUDPIn() + if !l.disablePacketOutput { + go l.loopUDPOut() + } + } + if l.setSystemProxy { + listenPort := M.SocksaddrFromNet(l.tcpListener.Addr()).Port + var listenAddrString string + listenAddr := l.listenOptions.Listen.Build(netip.IPv4Unspecified()) + if listenAddr.IsUnspecified() { + listenAddrString = "127.0.0.1" + } else { + listenAddrString = listenAddr.String() + } + systemProxy, err := settings.NewSystemProxy(l.ctx, M.ParseSocksaddrHostPort(listenAddrString, listenPort), l.systemProxySOCKS) + if err != nil { + return E.Cause(err, "initialize system proxy") + } + err = systemProxy.Enable() + if err != nil { + return E.Cause(err, "set system proxy") + } + l.systemProxy = systemProxy + } + return nil +} + +func (l *Listener) Close() error { + l.shutdown.Store(true) + var err error + if l.systemProxy != nil && l.systemProxy.IsEnabled() { + err = l.systemProxy.Disable() + } + return E.Errors(err, common.Close( + l.tcpListener, + common.PtrOrNil(l.udpConn), + )) +} + +func (l *Listener) TCPListener() net.Listener { + return l.tcpListener +} + +func (l *Listener) UDPConn() *net.UDPConn { + return l.udpConn +} + +func (l *Listener) ListenOptions() option.ListenOptions { + return l.listenOptions +} + +func ListenNetworkNamespace[T any](nameOrPath string, block func() (T, error)) (T, error) { + if nameOrPath != "" { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + currentNs, err := netns.Get() + if err != nil { + return common.DefaultValue[T](), E.Cause(err, "get current netns") + } + defer currentNs.Close() + defer netns.Set(currentNs) + var targetNs netns.NsHandle + if strings.HasPrefix(nameOrPath, "/") { + targetNs, err = netns.GetFromPath(nameOrPath) + } else { + targetNs, err = netns.GetFromName(nameOrPath) + } + if err != nil { + return common.DefaultValue[T](), E.Cause(err, "get netns ", nameOrPath) + } + defer targetNs.Close() + err = netns.Set(targetNs) + if err != nil { + return common.DefaultValue[T](), E.Cause(err, "set netns to ", nameOrPath) + } + } + return block() +} diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go new file mode 100644 index 00000000..54d84a6b --- /dev/null +++ b/common/listener/listener_tcp.go @@ -0,0 +1,111 @@ +package listener + +import ( + "net" + "net/netip" + "strings" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/redir" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "github.com/database64128/tfo-go/v2" +) + +func (l *Listener) ListenTCP() (net.Listener, error) { + //nolint:staticcheck + if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { + return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") + } + var err error + bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) + var listenConfig net.ListenConfig + if l.listenOptions.BindInterface != "" { + listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1)) + } + if l.listenOptions.RoutingMark != 0 { + listenConfig.Control = control.Append(listenConfig.Control, control.RoutingMark(uint32(l.listenOptions.RoutingMark))) + } + if l.listenOptions.ReuseAddr { + listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr()) + } + if l.listenOptions.DisableTCPKeepAlive { + listenConfig.KeepAlive = -1 + listenConfig.KeepAliveConfig.Enable = false + } else { + keepIdle := time.Duration(l.listenOptions.TCPKeepAlive) + if keepIdle == 0 { + keepIdle = C.TCPKeepAliveInitial + } + keepInterval := time.Duration(l.listenOptions.TCPKeepAliveInterval) + if keepInterval == 0 { + keepInterval = C.TCPKeepAliveInterval + } + listenConfig.KeepAliveConfig = net.KeepAliveConfig{ + Enable: true, + Idle: keepIdle, + Interval: keepInterval, + } + } + if l.listenOptions.TCPMultiPath { + listenConfig.SetMultipathTCP(true) + } + if l.tproxy { + listenConfig.Control = control.Append(listenConfig.Control, func(network, address string, conn syscall.RawConn) error { + return control.Raw(conn, func(fd uintptr) error { + return redir.TProxy(fd, !strings.HasSuffix(network, "4"), false) + }) + }) + } + tcpListener, err := ListenNetworkNamespace[net.Listener](l.listenOptions.NetNs, func() (net.Listener, error) { + if l.listenOptions.TCPFastOpen { + var tfoConfig tfo.ListenConfig + tfoConfig.ListenConfig = listenConfig + return tfoConfig.Listen(l.ctx, M.NetworkFromNetAddr(N.NetworkTCP, bindAddr.Addr), bindAddr.String()) + } else { + return listenConfig.Listen(l.ctx, M.NetworkFromNetAddr(N.NetworkTCP, bindAddr.Addr), bindAddr.String()) + } + }) + if err != nil { + return nil, err + } + l.logger.Info("tcp server started at ", tcpListener.Addr()) + l.tcpListener = tcpListener + return tcpListener, err +} + +func (l *Listener) loopTCPIn() { + tcpListener := l.tcpListener + var metadata adapter.InboundContext + for { + conn, err := tcpListener.Accept() + if err != nil { + //nolint:staticcheck + if netError, isNetError := err.(net.Error); isNetError && netError.Temporary() { + l.logger.Error(err) + continue + } + if l.shutdown.Load() && E.IsClosed(err) { + return + } + l.tcpListener.Close() + l.logger.Error("tcp listener closed: ", err) + continue + } + //nolint:staticcheck + metadata.InboundDetour = l.listenOptions.Detour + metadata.Source = M.SocksaddrFromNet(conn.RemoteAddr()).Unwrap() + metadata.OriginDestination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap() + ctx := log.ContextWithNewID(l.ctx) + l.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + go l.connHandler.NewConnectionEx(ctx, conn, metadata, nil) + } +} diff --git a/common/listener/listener_udp.go b/common/listener/listener_udp.go new file mode 100644 index 00000000..e689c8bb --- /dev/null +++ b/common/listener/listener_udp.go @@ -0,0 +1,207 @@ +package listener + +import ( + "context" + "net" + "net/netip" + "os" + "strings" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/redir" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func (l *Listener) ListenUDP() (net.PacketConn, error) { + bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) + var listenConfig net.ListenConfig + if l.listenOptions.BindInterface != "" { + listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1)) + } + if l.listenOptions.RoutingMark != 0 { + listenConfig.Control = control.Append(listenConfig.Control, control.RoutingMark(uint32(l.listenOptions.RoutingMark))) + } + if l.listenOptions.ReuseAddr { + listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr()) + } + var udpFragment bool + if l.listenOptions.UDPFragment != nil { + udpFragment = *l.listenOptions.UDPFragment + } else { + udpFragment = l.listenOptions.UDPFragmentDefault + } + if !udpFragment { + listenConfig.Control = control.Append(listenConfig.Control, control.DisableUDPFragment()) + } + if l.tproxy { + listenConfig.Control = control.Append(listenConfig.Control, func(network, address string, conn syscall.RawConn) error { + return control.Raw(conn, func(fd uintptr) error { + return redir.TProxy(fd, !strings.HasSuffix(network, "4"), true) + }) + }) + } + udpConn, err := ListenNetworkNamespace[net.PacketConn](l.listenOptions.NetNs, func() (net.PacketConn, error) { + return listenConfig.ListenPacket(l.ctx, M.NetworkFromNetAddr(N.NetworkUDP, bindAddr.Addr), bindAddr.String()) + }) + if err != nil { + return nil, err + } + l.udpConn = udpConn.(*net.UDPConn) + l.udpAddr = bindAddr + l.logger.Info("udp server started at ", udpConn.LocalAddr()) + return udpConn, err +} + +func (l *Listener) DialContext(dialer net.Dialer, ctx context.Context, network string, address string) (net.Conn, error) { + return ListenNetworkNamespace[net.Conn](l.listenOptions.NetNs, func() (net.Conn, error) { + if l.listenOptions.BindInterface != "" { + dialer.Control = control.Append(dialer.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1)) + } + if l.listenOptions.RoutingMark != 0 { + dialer.Control = control.Append(dialer.Control, control.RoutingMark(uint32(l.listenOptions.RoutingMark))) + } + if l.listenOptions.ReuseAddr { + dialer.Control = control.Append(dialer.Control, control.ReuseAddr()) + } + return dialer.DialContext(ctx, network, address) + }) +} + +func (l *Listener) ListenPacket(listenConfig net.ListenConfig, ctx context.Context, network string, address string) (net.PacketConn, error) { + return ListenNetworkNamespace[net.PacketConn](l.listenOptions.NetNs, func() (net.PacketConn, error) { + if l.listenOptions.BindInterface != "" { + listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1)) + } + if l.listenOptions.RoutingMark != 0 { + listenConfig.Control = control.Append(listenConfig.Control, control.RoutingMark(uint32(l.listenOptions.RoutingMark))) + } + if l.listenOptions.ReuseAddr { + listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr()) + } + return listenConfig.ListenPacket(ctx, network, address) + }) +} + +func (l *Listener) UDPAddr() M.Socksaddr { + return l.udpAddr +} + +func (l *Listener) PacketWriter() N.PacketWriter { + return (*packetWriter)(l) +} + +func (l *Listener) loopUDPIn() { + defer close(l.packetOutboundClosed) + var buffer *buf.Buffer + if !l.threadUnsafePacketWriter { + buffer = buf.NewPacket() + defer buffer.Release() + buffer.IncRef() + defer buffer.DecRef() + } + if l.oobPacketHandler != nil { + oob := make([]byte, 1024) + for { + if l.threadUnsafePacketWriter { + buffer = buf.NewPacket() + } else { + buffer.Reset() + } + n, oobN, _, addr, err := l.udpConn.ReadMsgUDPAddrPort(buffer.FreeBytes(), oob) + if err != nil { + if l.threadUnsafePacketWriter { + buffer.Release() + } + if l.shutdown.Load() && E.IsClosed(err) { + return + } + l.udpConn.Close() + l.logger.Error("udp listener closed: ", err) + return + } + buffer.Truncate(n) + l.oobPacketHandler.NewPacketEx(buffer, oob[:oobN], M.SocksaddrFromNetIP(addr).Unwrap()) + } + } else { + for { + if l.threadUnsafePacketWriter { + buffer = buf.NewPacket() + } else { + buffer.Reset() + } + n, addr, err := l.udpConn.ReadFromUDPAddrPort(buffer.FreeBytes()) + if err != nil { + if l.threadUnsafePacketWriter { + buffer.Release() + } + if l.shutdown.Load() && E.IsClosed(err) { + return + } + l.udpConn.Close() + l.logger.Error("udp listener closed: ", err) + return + } + buffer.Truncate(n) + l.packetHandler.NewPacketEx(buffer, M.SocksaddrFromNetIP(addr).Unwrap()) + } + } +} + +func (l *Listener) loopUDPOut() { + for { + select { + case packet := <-l.packetOutbound: + destination := packet.Destination.AddrPort() + _, err := l.udpConn.WriteToUDPAddrPort(packet.Buffer.Bytes(), destination) + packet.Buffer.Release() + N.PutPacketBuffer(packet) + if err != nil { + if l.shutdown.Load() && E.IsClosed(err) { + return + } + l.logger.Error("udp listener write back: ", destination, ": ", err) + continue + } + continue + case <-l.packetOutboundClosed: + } + for { + select { + case packet := <-l.packetOutbound: + packet.Buffer.Release() + N.PutPacketBuffer(packet) + default: + return + } + } + } +} + +type packetWriter Listener + +func (w *packetWriter) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + packet := N.NewPacketBuffer() + packet.Buffer = buffer + packet.Destination = destination + select { + case w.packetOutbound <- packet: + return nil + default: + buffer.Release() + N.PutPacketBuffer(packet) + if w.shutdown.Load() { + return os.ErrClosed + } + w.logger.Trace("dropped packet to ", destination) + return nil + } +} + +func (w *packetWriter) WriteIsThreadUnsafe() { +} diff --git a/common/mux/client.go b/common/mux/client.go new file mode 100644 index 00000000..bf103355 --- /dev/null +++ b/common/mux/client.go @@ -0,0 +1,59 @@ +package mux + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-mux" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type Client = mux.Client + +func NewClientWithOptions(dialer N.Dialer, logger logger.Logger, options option.OutboundMultiplexOptions) (*Client, error) { + if !options.Enabled { + return nil, nil + } + var brutalOptions mux.BrutalOptions + if options.Brutal != nil && options.Brutal.Enabled { + brutalOptions = mux.BrutalOptions{ + Enabled: true, + SendBPS: uint64(options.Brutal.UpMbps * C.MbpsToBps), + ReceiveBPS: uint64(options.Brutal.DownMbps * C.MbpsToBps), + } + if brutalOptions.SendBPS < mux.BrutalMinSpeedBPS { + return nil, E.New("brutal: invalid upload speed") + } + if brutalOptions.ReceiveBPS < mux.BrutalMinSpeedBPS { + return nil, E.New("brutal: invalid download speed") + } + } + return mux.NewClient(mux.Options{ + Dialer: &clientDialer{dialer}, + Logger: logger, + Protocol: options.Protocol, + MaxConnections: options.MaxConnections, + MinStreams: options.MinStreams, + MaxStreams: options.MaxStreams, + Padding: options.Padding, + Brutal: brutalOptions, + }) +} + +type clientDialer struct { + N.Dialer +} + +func (d *clientDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return d.Dialer.DialContext(adapter.OverrideContext(ctx), network, destination) +} + +func (d *clientDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return d.Dialer.ListenPacket(adapter.OverrideContext(ctx), destination) +} diff --git a/common/mux/router.go b/common/mux/router.go new file mode 100644 index 00000000..ec788086 --- /dev/null +++ b/common/mux/router.go @@ -0,0 +1,80 @@ +package mux + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-mux" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +type Router struct { + router adapter.ConnectionRouterEx + service *mux.Service +} + +func NewRouterWithOptions(router adapter.ConnectionRouterEx, logger logger.ContextLogger, options option.InboundMultiplexOptions) (adapter.ConnectionRouterEx, error) { + if !options.Enabled { + return router, nil + } + var brutalOptions mux.BrutalOptions + if options.Brutal != nil && options.Brutal.Enabled { + brutalOptions = mux.BrutalOptions{ + Enabled: true, + SendBPS: uint64(options.Brutal.UpMbps * C.MbpsToBps), + ReceiveBPS: uint64(options.Brutal.DownMbps * C.MbpsToBps), + } + if brutalOptions.SendBPS < mux.BrutalMinSpeedBPS { + return nil, E.New("brutal: invalid upload speed") + } + if brutalOptions.ReceiveBPS < mux.BrutalMinSpeedBPS { + return nil, E.New("brutal: invalid download speed") + } + } + service, err := mux.NewService(mux.ServiceOptions{ + NewStreamContext: func(ctx context.Context, conn net.Conn) context.Context { + return log.ContextWithNewID(ctx) + }, + Logger: logger, + HandlerEx: adapter.NewRouteContextHandlerEx(router), + Padding: options.Padding, + Brutal: brutalOptions, + }) + if err != nil { + return nil, err + } + return &Router{router, service}, nil +} + +// Deprecated: Use RouteConnectionEx instead. +func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + if metadata.Destination == mux.Destination { + // TODO: check if WithContext is necessary + return r.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, adapter.UpstreamMetadata(metadata)) + } else { + return r.router.RouteConnection(ctx, conn, metadata) + } +} + +// Deprecated: Use RoutePacketConnectionEx instead. +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + return r.router.RoutePacketConnection(ctx, conn, metadata) +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination == mux.Destination { + r.service.NewConnectionEx(adapter.WithContext(ctx, &metadata), conn, metadata.Source, metadata.Destination, onClose) + return + } + r.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + r.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/common/networkquality/http.go b/common/networkquality/http.go new file mode 100644 index 00000000..f9ff2a4a --- /dev/null +++ b/common/networkquality/http.go @@ -0,0 +1,142 @@ +package networkquality + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + + C "github.com/sagernet/sing-box/constant" + sBufio "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func FormatBitrate(bps int64) string { + switch { + case bps >= 1_000_000_000: + return fmt.Sprintf("%.1f Gbps", float64(bps)/1_000_000_000) + case bps >= 1_000_000: + return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000) + case bps >= 1_000: + return fmt.Sprintf("%.1f Kbps", float64(bps)/1_000) + default: + return fmt.Sprintf("%d bps", bps) + } +} + +func NewHTTPClient(dialer N.Dialer) *http.Client { + transport := &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + } + if dialer != nil { + transport.DialContext = func(ctx context.Context, network string, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + } + } + return &http.Client{Transport: transport} +} + +func baseTransportFromClient(client *http.Client) (*http.Transport, error) { + if client == nil { + return nil, E.New("http client is nil") + } + if client.Transport == nil { + return http.DefaultTransport.(*http.Transport).Clone(), nil + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + return nil, E.New("http client transport must be *http.Transport") + } + return transport.Clone(), nil +} + +func newMeasurementClient( + baseClient *http.Client, + connectEndpoint string, + singleConnection bool, + disableKeepAlives bool, + readCounters []N.CountFunc, + writeCounters []N.CountFunc, +) (*http.Client, error) { + transport, err := baseTransportFromClient(baseClient) + if err != nil { + return nil, err + } + transport.DisableCompression = true + transport.DisableKeepAlives = disableKeepAlives + if singleConnection { + transport.MaxConnsPerHost = 1 + transport.MaxIdleConnsPerHost = 1 + transport.MaxIdleConns = 1 + } + + baseDialContext := transport.DialContext + if baseDialContext == nil { + dialer := &net.Dialer{} + baseDialContext = dialer.DialContext + } + transport.DialContext = func(ctx context.Context, network string, addr string) (net.Conn, error) { + dialAddr := addr + if connectEndpoint != "" { + dialAddr = rewriteDialAddress(addr, connectEndpoint) + } + conn, dialErr := baseDialContext(ctx, network, dialAddr) + if dialErr != nil { + return nil, dialErr + } + if len(readCounters) > 0 || len(writeCounters) > 0 { + return sBufio.NewCounterConn(conn, readCounters, writeCounters), nil + } + return conn, nil + } + + return &http.Client{ + Transport: transport, + CheckRedirect: baseClient.CheckRedirect, + Jar: baseClient.Jar, + Timeout: baseClient.Timeout, + }, nil +} + +type MeasurementClientFactory func( + connectEndpoint string, + singleConnection bool, + disableKeepAlives bool, + readCounters []N.CountFunc, + writeCounters []N.CountFunc, +) (*http.Client, error) + +func defaultMeasurementClientFactory(baseClient *http.Client) MeasurementClientFactory { + return func(connectEndpoint string, singleConnection, disableKeepAlives bool, readCounters, writeCounters []N.CountFunc) (*http.Client, error) { + return newMeasurementClient(baseClient, connectEndpoint, singleConnection, disableKeepAlives, readCounters, writeCounters) + } +} + +func NewOptionalHTTP3Factory(dialer N.Dialer, useHTTP3 bool) (MeasurementClientFactory, error) { + if !useHTTP3 { + return nil, nil + } + return NewHTTP3MeasurementClientFactory(dialer) +} + +func rewriteDialAddress(addr string, connectEndpoint string) string { + connectEndpoint = strings.TrimSpace(connectEndpoint) + host, port, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + endpointHost, endpointPort, err := net.SplitHostPort(connectEndpoint) + if err == nil { + host = endpointHost + if endpointPort != "" { + port = endpointPort + } + } else if connectEndpoint != "" { + host = connectEndpoint + } + return net.JoinHostPort(host, port) +} diff --git a/common/networkquality/http3.go b/common/networkquality/http3.go new file mode 100644 index 00000000..0e907821 --- /dev/null +++ b/common/networkquality/http3.go @@ -0,0 +1,55 @@ +//go:build with_quic + +package networkquality + +import ( + "context" + "crypto/tls" + "net" + "net/http" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + sBufio "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func NewHTTP3MeasurementClientFactory(dialer N.Dialer) (MeasurementClientFactory, error) { + // singleConnection and disableKeepAlives are not applied: + // HTTP/3 multiplexes streams over a single QUIC connection by default. + return func(connectEndpoint string, _, _ bool, readCounters, writeCounters []N.CountFunc) (*http.Client, error) { + transport := &http3.Transport{ + Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) { + dialAddr := addr + if connectEndpoint != "" { + dialAddr = rewriteDialAddress(addr, connectEndpoint) + } + destination := M.ParseSocksaddr(dialAddr) + var udpConn net.Conn + var dialErr error + if dialer != nil { + udpConn, dialErr = dialer.DialContext(ctx, N.NetworkUDP, destination) + } else { + var netDialer net.Dialer + udpConn, dialErr = netDialer.DialContext(ctx, N.NetworkUDP, destination.String()) + } + if dialErr != nil { + return nil, dialErr + } + wrappedConn := udpConn + if len(readCounters) > 0 || len(writeCounters) > 0 { + wrappedConn = sBufio.NewCounterConn(udpConn, readCounters, writeCounters) + } + packetConn := sBufio.NewUnbindPacketConn(wrappedConn) + quicConn, dialErr := quic.DialEarly(ctx, packetConn, udpConn.RemoteAddr(), tlsCfg, cfg) + if dialErr != nil { + udpConn.Close() + return nil, dialErr + } + return quicConn, nil + }, + } + return &http.Client{Transport: transport}, nil + }, nil +} diff --git a/common/networkquality/http3_stub.go b/common/networkquality/http3_stub.go new file mode 100644 index 00000000..632837e6 --- /dev/null +++ b/common/networkquality/http3_stub.go @@ -0,0 +1,12 @@ +//go:build !with_quic + +package networkquality + +import ( + C "github.com/sagernet/sing-box/constant" + N "github.com/sagernet/sing/common/network" +) + +func NewHTTP3MeasurementClientFactory(dialer N.Dialer) (MeasurementClientFactory, error) { + return nil, C.ErrQUICNotIncluded +} diff --git a/common/networkquality/networkquality.go b/common/networkquality/networkquality.go new file mode 100644 index 00000000..a4c73472 --- /dev/null +++ b/common/networkquality/networkquality.go @@ -0,0 +1,1413 @@ +package networkquality + +import ( + "context" + "crypto/tls" + "encoding/json" + "io" + "math" + "math/rand" + "net/http" + "net/http/httptrace" + "net/url" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + sBufio "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" +) + +const DefaultConfigURL = "https://mensura.cdn-apple.com/api/v1/gm/config" + +type Config struct { + Version int `json:"version"` + TestEndpoint string `json:"test_endpoint"` + URLs URLs `json:"urls"` +} + +type URLs struct { + SmallHTTPSDownloadURL string `json:"small_https_download_url"` + LargeHTTPSDownloadURL string `json:"large_https_download_url"` + HTTPSUploadURL string `json:"https_upload_url"` + SmallDownloadURL string `json:"small_download_url"` + LargeDownloadURL string `json:"large_download_url"` + UploadURL string `json:"upload_url"` +} + +func (u *URLs) smallDownloadURL() string { + if u.SmallHTTPSDownloadURL != "" { + return u.SmallHTTPSDownloadURL + } + return u.SmallDownloadURL +} + +func (u *URLs) largeDownloadURL() string { + if u.LargeHTTPSDownloadURL != "" { + return u.LargeHTTPSDownloadURL + } + return u.LargeDownloadURL +} + +func (u *URLs) uploadURL() string { + if u.HTTPSUploadURL != "" { + return u.HTTPSUploadURL + } + return u.UploadURL +} + +type Accuracy int32 + +const ( + AccuracyLow Accuracy = 0 + AccuracyMedium Accuracy = 1 + AccuracyHigh Accuracy = 2 +) + +func (a Accuracy) String() string { + switch a { + case AccuracyHigh: + return "High" + case AccuracyMedium: + return "Medium" + default: + return "Low" + } +} + +type Result struct { + DownloadCapacity int64 + UploadCapacity int64 + DownloadRPM int32 + UploadRPM int32 + IdleLatencyMs int32 + DownloadCapacityAccuracy Accuracy + UploadCapacityAccuracy Accuracy + DownloadRPMAccuracy Accuracy + UploadRPMAccuracy Accuracy +} + +type Progress struct { + Phase Phase + DownloadCapacity int64 + UploadCapacity int64 + DownloadRPM int32 + UploadRPM int32 + IdleLatencyMs int32 + ElapsedMs int64 + DownloadCapacityAccuracy Accuracy + UploadCapacityAccuracy Accuracy + DownloadRPMAccuracy Accuracy + UploadRPMAccuracy Accuracy +} + +type Phase int32 + +const ( + PhaseIdle Phase = 0 + PhaseDownload Phase = 1 + PhaseUpload Phase = 2 + PhaseDone Phase = 3 +) + +type Options struct { + ConfigURL string + HTTPClient *http.Client + NewMeasurementClient MeasurementClientFactory + Serial bool + MaxRuntime time.Duration + OnProgress func(Progress) + Context context.Context +} + +const DefaultMaxRuntime = 20 * time.Second + +type measurementSettings struct { + idleProbeCount int + testTimeout time.Duration + stabilityInterval time.Duration + sampleInterval time.Duration + progressInterval time.Duration + maxProbesPerSecond int + initialConnections int + maxConnections int + movingAvgDistance int + trimPercent int + stdDevTolerancePct float64 + maxProbeCapacityPct float64 +} + +var settings = measurementSettings{ + idleProbeCount: 5, + testTimeout: DefaultMaxRuntime, + stabilityInterval: time.Second, + sampleInterval: 250 * time.Millisecond, + progressInterval: 500 * time.Millisecond, + maxProbesPerSecond: 100, + initialConnections: 1, + maxConnections: 16, + movingAvgDistance: 4, + trimPercent: 5, + stdDevTolerancePct: 5, + maxProbeCapacityPct: 0.05, +} + +type resolvedConfig struct { + smallURL *url.URL + largeURL *url.URL + uploadURL *url.URL + connectEndpoint string +} + +type directionPlan struct { + dataURL *url.URL + probeURL *url.URL + connectEndpoint string + isUpload bool +} + +type probeTrace struct { + reused bool + connectStart time.Time + connectDone time.Time + tlsStart time.Time + tlsDone time.Time + tlsVersion uint16 + gotConn time.Time + wroteRequest time.Time + firstResponseByte time.Time +} + +type probeMeasurement struct { + total time.Duration + tcp time.Duration + tls time.Duration + httpFirst time.Duration + httpLoaded time.Duration + bytes int64 + reused bool +} + +type probeRound struct { + interval int + tcp time.Duration + tls time.Duration + httpFirst time.Duration + httpLoaded time.Duration +} + +func (p probeRound) responsivenessLatency() float64 { + var foreignSamples []float64 + if p.tcp > 0 { + foreignSamples = append(foreignSamples, durationMillis(p.tcp)) + } + if p.tls > 0 { + foreignSamples = append(foreignSamples, durationMillis(p.tls)) + } + if p.httpFirst > 0 { + foreignSamples = append(foreignSamples, durationMillis(p.httpFirst)) + } + if len(foreignSamples) == 0 || p.httpLoaded <= 0 { + return 0 + } + return (meanFloat64s(foreignSamples) + durationMillis(p.httpLoaded)) / 2 +} + +const maxConsecutiveErrors = 3 + +type loadConnection struct { + client *http.Client + dataURL *url.URL + isUpload bool + active atomic.Bool + ready atomic.Bool +} + +func (c *loadConnection) run(ctx context.Context, onError func(error)) { + defer c.client.CloseIdleConnections() + markActive := func() { + c.ready.Store(true) + c.active.Store(true) + } + var consecutiveErrors int + for { + select { + case <-ctx.Done(): + return + default: + } + var err error + if c.isUpload { + err = runUploadRequest(ctx, c.client, c.dataURL.String(), markActive) + } else { + err = runDownloadRequest(ctx, c.client, c.dataURL.String(), markActive) + } + c.active.Store(false) + if err != nil { + if ctx.Err() != nil { + return + } + consecutiveErrors++ + if consecutiveErrors > maxConsecutiveErrors { + onError(err) + return + } + c.client.CloseIdleConnections() + continue + } + consecutiveErrors = 0 + } +} + +type intervalThroughput struct { + interval int + bps float64 +} + +type intervalWindow struct { + lower int + upper int +} + +type stabilityTracker struct { + window int + stdDevTolerancePct float64 + instantaneous []float64 + movingAverages []float64 +} + +func (s *stabilityTracker) add(value float64) bool { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return false + } + s.instantaneous = append(s.instantaneous, value) + if len(s.instantaneous) > s.window { + s.instantaneous = s.instantaneous[len(s.instantaneous)-s.window:] + } + s.movingAverages = append(s.movingAverages, meanFloat64s(s.instantaneous)) + if len(s.movingAverages) > s.window { + s.movingAverages = s.movingAverages[len(s.movingAverages)-s.window:] + } + return s.stable() +} + +func (s *stabilityTracker) ready() bool { + return len(s.movingAverages) >= s.window +} + +func (s *stabilityTracker) accuracy() Accuracy { + if s.stable() { + return AccuracyHigh + } + if s.ready() { + return AccuracyMedium + } + return AccuracyLow +} + +func (s *stabilityTracker) stable() bool { + if len(s.movingAverages) < s.window { + return false + } + currentAverage := s.movingAverages[len(s.movingAverages)-1] + if currentAverage <= 0 { + return false + } + return stdDevFloat64s(s.movingAverages) <= currentAverage*(s.stdDevTolerancePct/100) +} + +type directionMeasurement struct { + capacity int64 + rpm int32 + capacityAccuracy Accuracy + rpmAccuracy Accuracy +} + +type directionRunner struct { + factory MeasurementClientFactory + plan directionPlan + probeBytes int64 + + errCh chan error + errOnce sync.Once + wg sync.WaitGroup + + totalBytes atomic.Int64 + currentCapacity atomic.Int64 + currentRPM atomic.Int32 + currentInterval atomic.Int64 + + connMu sync.Mutex + connections []*loadConnection + + probeMu sync.Mutex + probeRounds []probeRound + intervalProbeValues []float64 + responsivenessWindow *intervalWindow + throughputs []intervalThroughput + throughputWindow *intervalWindow +} + +func newDirectionRunner(factory MeasurementClientFactory, plan directionPlan, probeBytes int64) *directionRunner { + return &directionRunner{ + factory: factory, + plan: plan, + probeBytes: probeBytes, + errCh: make(chan error, 1), + } +} + +func (r *directionRunner) fail(err error) { + if err == nil { + return + } + r.errOnce.Do(func() { + select { + case r.errCh <- err: + default: + } + }) +} + +func (r *directionRunner) onConnectionFailed(err error) { + r.connMu.Lock() + activeCount := 0 + for _, conn := range r.connections { + if conn.active.Load() { + activeCount++ + } + } + r.connMu.Unlock() + if activeCount == 0 { + r.fail(err) + } +} + +func (r *directionRunner) addConnection(ctx context.Context) error { + counter := N.CountFunc(func(n int64) { r.totalBytes.Add(n) }) + var readCounters, writeCounters []N.CountFunc + if r.plan.isUpload { + writeCounters = []N.CountFunc{counter} + } else { + readCounters = []N.CountFunc{counter} + } + client, err := r.factory(r.plan.connectEndpoint, true, false, readCounters, writeCounters) + if err != nil { + return err + } + conn := &loadConnection{ + client: client, + dataURL: r.plan.dataURL, + isUpload: r.plan.isUpload, + } + r.connMu.Lock() + r.connections = append(r.connections, conn) + r.connMu.Unlock() + r.wg.Add(1) + go func() { + defer r.wg.Done() + conn.run(ctx, r.onConnectionFailed) + }() + return nil +} + +func (r *directionRunner) connectionCount() int { + r.connMu.Lock() + defer r.connMu.Unlock() + return len(r.connections) +} + +func (r *directionRunner) pickReadyConnection() *loadConnection { + r.connMu.Lock() + defer r.connMu.Unlock() + var ready []*loadConnection + for _, conn := range r.connections { + if conn.ready.Load() && conn.active.Load() { + ready = append(ready, conn) + } + } + if len(ready) == 0 { + return nil + } + return ready[rand.Intn(len(ready))] +} + +func (r *directionRunner) startProber(ctx context.Context) { + r.wg.Add(1) + go func() { + defer r.wg.Done() + ticker := time.NewTicker(r.probeInterval()) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + conn := r.pickReadyConnection() + if conn == nil { + continue + } + go func(selfClient *http.Client) { + foreignClient, err := r.factory(r.plan.connectEndpoint, true, true, nil, nil) + if err != nil { + return + } + round, err := collectProbeRound(ctx, foreignClient, selfClient, r.plan.probeURL.String()) + foreignClient.CloseIdleConnections() + if err != nil { + return + } + r.recordProbeRound(probeRound{ + interval: int(r.currentInterval.Load()), + tcp: round.tcp, + tls: round.tls, + httpFirst: round.httpFirst, + httpLoaded: round.httpLoaded, + }) + }(conn.client) + ticker.Reset(r.probeInterval()) + } + }() +} + +func (r *directionRunner) probeInterval() time.Duration { + interval := time.Second / time.Duration(settings.maxProbesPerSecond) + capacity := r.currentCapacity.Load() + if capacity <= 0 || r.probeBytes <= 0 || settings.maxProbeCapacityPct <= 0 { + return interval + } + bitsPerRound := float64(r.probeBytes*2) * 8 + minSeconds := bitsPerRound / (float64(capacity) * settings.maxProbeCapacityPct) + if minSeconds <= 0 { + return interval + } + capacityInterval := time.Duration(minSeconds * float64(time.Second)) + if capacityInterval > interval { + interval = capacityInterval + } + return interval +} + +func (r *directionRunner) recordProbeRound(round probeRound) { + r.probeMu.Lock() + r.probeRounds = append(r.probeRounds, round) + if latency := round.responsivenessLatency(); latency > 0 { + r.intervalProbeValues = append(r.intervalProbeValues, latency) + } + r.currentRPM.Store(calculateRPM(r.probeRounds)) + r.probeMu.Unlock() +} + +func (r *directionRunner) swapIntervalProbeValues() []float64 { + r.probeMu.Lock() + defer r.probeMu.Unlock() + values := append([]float64(nil), r.intervalProbeValues...) + r.intervalProbeValues = nil + return values +} + +func (r *directionRunner) setResponsivenessWindow(currentInterval int) { + lower := currentInterval - settings.movingAvgDistance + 1 + if lower < 0 { + lower = 0 + } + r.probeMu.Lock() + r.responsivenessWindow = &intervalWindow{lower: lower, upper: currentInterval} + r.probeMu.Unlock() +} + +func (r *directionRunner) recordThroughput(interval int, bps float64) { + r.probeMu.Lock() + r.throughputs = append(r.throughputs, intervalThroughput{interval: interval, bps: bps}) + r.probeMu.Unlock() +} + +func (r *directionRunner) setThroughputWindow(currentInterval int) { + lower := currentInterval - settings.movingAvgDistance + 1 + if lower < 0 { + lower = 0 + } + r.probeMu.Lock() + r.throughputWindow = &intervalWindow{lower: lower, upper: currentInterval} + r.probeMu.Unlock() +} + +func (r *directionRunner) finalRPM() int32 { + r.probeMu.Lock() + defer r.probeMu.Unlock() + if r.responsivenessWindow == nil { + return calculateRPM(r.probeRounds) + } + var rounds []probeRound + for _, round := range r.probeRounds { + if round.interval >= r.responsivenessWindow.lower && round.interval <= r.responsivenessWindow.upper { + rounds = append(rounds, round) + } + } + if len(rounds) == 0 { + rounds = r.probeRounds + } + return calculateRPM(rounds) +} + +func (r *directionRunner) finalCapacity(totalDuration time.Duration) int64 { + r.probeMu.Lock() + defer r.probeMu.Unlock() + var samples []float64 + if r.throughputWindow != nil { + for _, sample := range r.throughputs { + if sample.interval >= r.throughputWindow.lower && sample.interval <= r.throughputWindow.upper { + samples = append(samples, sample.bps) + } + } + } + if len(samples) == 0 { + for _, sample := range r.throughputs { + samples = append(samples, sample.bps) + } + } + if len(samples) > 0 { + return int64(math.Round(upperTrimmedMean(samples, settings.trimPercent))) + } + if totalDuration > 0 { + return int64(float64(r.totalBytes.Load()) * 8 / totalDuration.Seconds()) + } + return 0 +} + +func (r *directionRunner) wait() { + r.wg.Wait() +} + +func Run(options Options) (*Result, error) { + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + if options.HTTPClient == nil { + return nil, E.New("http client is required") + } + maxRuntime, err := normalizeMaxRuntime(options.MaxRuntime) + if err != nil { + return nil, err + } + configURL := resolveConfigURL(options.ConfigURL) + config, err := fetchConfig(ctx, options.HTTPClient, configURL) + if err != nil { + return nil, E.Cause(err, "fetch config") + } + resolved, err := validateConfig(config) + if err != nil { + return nil, E.Cause(err, "validate config") + } + + start := time.Now() + report := func(progress Progress) { + if options.OnProgress == nil { + return + } + progress.ElapsedMs = time.Since(start).Milliseconds() + options.OnProgress(progress) + } + + factory := options.NewMeasurementClient + if factory == nil { + factory = defaultMeasurementClientFactory(options.HTTPClient) + } + + report(Progress{Phase: PhaseIdle}) + idleLatency, probeBytes, err := measureIdleLatency(ctx, factory, resolved) + if err != nil { + return nil, E.Cause(err, "measure idle latency") + } + report(Progress{Phase: PhaseIdle, IdleLatencyMs: idleLatency}) + + start = time.Now() + + var download, upload *directionMeasurement + if options.Serial { + download, upload, err = measureSerial( + ctx, + factory, + resolved, + idleLatency, + probeBytes, + maxRuntime, + report, + ) + } else { + download, upload, err = measureParallel( + ctx, + factory, + resolved, + idleLatency, + probeBytes, + maxRuntime, + report, + ) + } + if err != nil { + return nil, err + } + + result := &Result{ + DownloadCapacity: download.capacity, + UploadCapacity: upload.capacity, + DownloadRPM: download.rpm, + UploadRPM: upload.rpm, + IdleLatencyMs: idleLatency, + DownloadCapacityAccuracy: download.capacityAccuracy, + UploadCapacityAccuracy: upload.capacityAccuracy, + DownloadRPMAccuracy: download.rpmAccuracy, + UploadRPMAccuracy: upload.rpmAccuracy, + } + report(Progress{ + Phase: PhaseDone, + DownloadCapacity: result.DownloadCapacity, + UploadCapacity: result.UploadCapacity, + DownloadRPM: result.DownloadRPM, + UploadRPM: result.UploadRPM, + IdleLatencyMs: result.IdleLatencyMs, + DownloadCapacityAccuracy: result.DownloadCapacityAccuracy, + UploadCapacityAccuracy: result.UploadCapacityAccuracy, + DownloadRPMAccuracy: result.DownloadRPMAccuracy, + UploadRPMAccuracy: result.UploadRPMAccuracy, + }) + return result, nil +} + +func normalizeMaxRuntime(maxRuntime time.Duration) (time.Duration, error) { + if maxRuntime == 0 { + return settings.testTimeout, nil + } + if maxRuntime < 0 { + return 0, E.New("max runtime must be positive") + } + return maxRuntime, nil +} + +func measureSerial( + ctx context.Context, + factory MeasurementClientFactory, + resolved *resolvedConfig, + idleLatency int32, + probeBytes int64, + maxRuntime time.Duration, + report func(Progress), +) (*directionMeasurement, *directionMeasurement, error) { + downloadRuntime, uploadRuntime := splitRuntimeBudget(maxRuntime, 2) + report(Progress{Phase: PhaseDownload, IdleLatencyMs: idleLatency}) + download, err := measureDirection(ctx, factory, directionPlan{ + dataURL: resolved.largeURL, + probeURL: resolved.smallURL, + connectEndpoint: resolved.connectEndpoint, + }, probeBytes, downloadRuntime, func(capacity int64, rpm int32) { + report(Progress{ + Phase: PhaseDownload, + DownloadCapacity: capacity, + DownloadRPM: rpm, + IdleLatencyMs: idleLatency, + }) + }) + if err != nil { + return nil, nil, E.Cause(err, "measure download") + } + + report(Progress{ + Phase: PhaseUpload, + DownloadCapacity: download.capacity, + DownloadRPM: download.rpm, + IdleLatencyMs: idleLatency, + }) + upload, err := measureDirection(ctx, factory, directionPlan{ + dataURL: resolved.uploadURL, + probeURL: resolved.smallURL, + connectEndpoint: resolved.connectEndpoint, + isUpload: true, + }, probeBytes, uploadRuntime, func(capacity int64, rpm int32) { + report(Progress{ + Phase: PhaseUpload, + DownloadCapacity: download.capacity, + UploadCapacity: capacity, + DownloadRPM: download.rpm, + UploadRPM: rpm, + IdleLatencyMs: idleLatency, + }) + }) + if err != nil { + return nil, nil, E.Cause(err, "measure upload") + } + return download, upload, nil +} + +func measureParallel( + ctx context.Context, + factory MeasurementClientFactory, + resolved *resolvedConfig, + idleLatency int32, + probeBytes int64, + maxRuntime time.Duration, + report func(Progress), +) (*directionMeasurement, *directionMeasurement, error) { + type parallelResult struct { + measurement *directionMeasurement + err error + } + type progressState struct { + sync.Mutex + downloadCapacity int64 + uploadCapacity int64 + downloadRPM int32 + uploadRPM int32 + } + + parallelCtx, cancel := context.WithCancel(ctx) + defer cancel() + + report(Progress{Phase: PhaseDownload, IdleLatencyMs: idleLatency}) + report(Progress{Phase: PhaseUpload, IdleLatencyMs: idleLatency}) + + var state progressState + sendProgress := func(phase Phase, capacity int64, rpm int32) { + state.Lock() + if phase == PhaseDownload { + state.downloadCapacity = capacity + state.downloadRPM = rpm + } else { + state.uploadCapacity = capacity + state.uploadRPM = rpm + } + snapshot := Progress{ + Phase: phase, + DownloadCapacity: state.downloadCapacity, + UploadCapacity: state.uploadCapacity, + DownloadRPM: state.downloadRPM, + UploadRPM: state.uploadRPM, + IdleLatencyMs: idleLatency, + } + state.Unlock() + report(snapshot) + } + + var wg sync.WaitGroup + downloadCh := make(chan parallelResult, 1) + uploadCh := make(chan parallelResult, 1) + wg.Add(2) + go func() { + defer wg.Done() + measurement, err := measureDirection(parallelCtx, factory, directionPlan{ + dataURL: resolved.largeURL, + probeURL: resolved.smallURL, + connectEndpoint: resolved.connectEndpoint, + }, probeBytes, maxRuntime, func(capacity int64, rpm int32) { + sendProgress(PhaseDownload, capacity, rpm) + }) + if err != nil { + cancel() + downloadCh <- parallelResult{err: E.Cause(err, "measure download")} + return + } + downloadCh <- parallelResult{measurement: measurement} + }() + go func() { + defer wg.Done() + measurement, err := measureDirection(parallelCtx, factory, directionPlan{ + dataURL: resolved.uploadURL, + probeURL: resolved.smallURL, + connectEndpoint: resolved.connectEndpoint, + isUpload: true, + }, probeBytes, maxRuntime, func(capacity int64, rpm int32) { + sendProgress(PhaseUpload, capacity, rpm) + }) + if err != nil { + cancel() + uploadCh <- parallelResult{err: E.Cause(err, "measure upload")} + return + } + uploadCh <- parallelResult{measurement: measurement} + }() + + download := <-downloadCh + upload := <-uploadCh + wg.Wait() + if download.err != nil { + return nil, nil, download.err + } + if upload.err != nil { + return nil, nil, upload.err + } + return download.measurement, upload.measurement, nil +} + +func resolveConfigURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return DefaultConfigURL + } + if !strings.Contains(rawURL, "://") && !strings.Contains(rawURL, "/") { + return "https://" + rawURL + "/.well-known/nq" + } + parsedURL, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + if parsedURL.Scheme != "" && parsedURL.Host != "" && (parsedURL.Path == "" || parsedURL.Path == "/") { + parsedURL.Path = "/.well-known/nq" + return parsedURL.String() + } + return rawURL +} + +func fetchConfig(ctx context.Context, client *http.Client, configURL string) (*Config, error) { + req, err := newRequest(ctx, http.MethodGet, configURL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if err = validateResponse(resp); err != nil { + return nil, err + } + var config Config + if err = json.NewDecoder(resp.Body).Decode(&config); err != nil { + return nil, E.Cause(err, "decode config") + } + return &config, nil +} + +func validateConfig(config *Config) (*resolvedConfig, error) { + if config == nil { + return nil, E.New("config is nil") + } + if config.Version != 1 { + return nil, E.New("unsupported config version: ", config.Version) + } + parseURL := func(name string, rawURL string) (*url.URL, error) { + if rawURL == "" { + return nil, E.New("config missing required URL: ", name) + } + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, E.Cause(err, "parse "+name) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, E.New("unsupported URL scheme in ", name, ": ", parsedURL.Scheme) + } + if parsedURL.Host == "" { + return nil, E.New("config missing host in ", name) + } + return parsedURL, nil + } + + smallURL, err := parseURL("small_download_url", config.URLs.smallDownloadURL()) + if err != nil { + return nil, err + } + largeURL, err := parseURL("large_download_url", config.URLs.largeDownloadURL()) + if err != nil { + return nil, err + } + uploadURL, err := parseURL("upload_url", config.URLs.uploadURL()) + if err != nil { + return nil, err + } + + if smallURL.Host != largeURL.Host || smallURL.Host != uploadURL.Host { + return nil, E.New("config URLs must use the same host") + } + + return &resolvedConfig{ + smallURL: smallURL, + largeURL: largeURL, + uploadURL: uploadURL, + connectEndpoint: strings.TrimSpace(config.TestEndpoint), + }, nil +} + +func measureIdleLatency(ctx context.Context, factory MeasurementClientFactory, config *resolvedConfig) (int32, int64, error) { + var latencies []int64 + var maxProbeBytes int64 + for i := 0; i < settings.idleProbeCount; i++ { + select { + case <-ctx.Done(): + return 0, 0, ctx.Err() + default: + } + client, err := factory(config.connectEndpoint, true, true, nil, nil) + if err != nil { + return 0, 0, err + } + measurement, err := runProbe(ctx, client, config.smallURL.String(), false) + client.CloseIdleConnections() + if err != nil { + return 0, 0, err + } + latencies = append(latencies, measurement.total.Milliseconds()) + if measurement.bytes > maxProbeBytes { + maxProbeBytes = measurement.bytes + } + } + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + return int32(latencies[len(latencies)/2]), maxProbeBytes, nil +} + +func measureDirection( + ctx context.Context, + factory MeasurementClientFactory, + plan directionPlan, + probeBytes int64, + maxRuntime time.Duration, + onProgress func(capacity int64, rpm int32), +) (*directionMeasurement, error) { + phaseCtx, phaseCancel := context.WithTimeout(ctx, maxRuntime) + defer phaseCancel() + + runner := newDirectionRunner(factory, plan, probeBytes) + defer runner.wait() + + for i := 0; i < settings.initialConnections; i++ { + err := runner.addConnection(phaseCtx) + if err != nil { + return nil, err + } + } + + runner.startProber(phaseCtx) + + throughputTracker := stabilityTracker{ + window: settings.movingAvgDistance, + stdDevTolerancePct: settings.stdDevTolerancePct, + } + responsivenessTracker := stabilityTracker{ + window: settings.movingAvgDistance, + stdDevTolerancePct: settings.stdDevTolerancePct, + } + + start := time.Now() + sampleTicker := time.NewTicker(settings.sampleInterval) + defer sampleTicker.Stop() + intervalTicker := time.NewTicker(settings.stabilityInterval) + defer intervalTicker.Stop() + progressTicker := time.NewTicker(settings.progressInterval) + defer progressTicker.Stop() + + prevSampleBytes := int64(0) + prevSampleTime := start + prevIntervalBytes := int64(0) + prevIntervalTime := start + var ewmaCapacity float64 + var goodputSaturated bool + var intervalIndex int + + for { + select { + case err := <-runner.errCh: + return nil, err + case now := <-sampleTicker.C: + currentBytes := runner.totalBytes.Load() + elapsed := now.Sub(prevSampleTime).Seconds() + if elapsed > 0 { + instantaneousBps := float64(currentBytes-prevSampleBytes) * 8 / elapsed + if ewmaCapacity == 0 { + ewmaCapacity = instantaneousBps + } else { + ewmaCapacity = 0.3*instantaneousBps + 0.7*ewmaCapacity + } + runner.currentCapacity.Store(int64(ewmaCapacity)) + } + prevSampleBytes = currentBytes + prevSampleTime = now + case <-intervalTicker.C: + now := time.Now() + currentBytes := runner.totalBytes.Load() + elapsed := now.Sub(prevIntervalTime).Seconds() + if elapsed > 0 { + intervalBps := float64(currentBytes-prevIntervalBytes) * 8 / elapsed + runner.recordThroughput(intervalIndex, intervalBps) + throughputStable := throughputTracker.add(intervalBps) + if throughputStable && runner.throughputWindow == nil { + runner.setThroughputWindow(intervalIndex) + } + if !goodputSaturated && (throughputStable || (runner.connectionCount() >= settings.maxConnections && throughputTracker.ready())) { + goodputSaturated = true + } + if runner.connectionCount() < settings.maxConnections { + err := runner.addConnection(phaseCtx) + if err != nil { + return nil, err + } + } + } + if goodputSaturated { + if values := runner.swapIntervalProbeValues(); len(values) > 0 { + if responsivenessTracker.add(upperTrimmedMean(values, settings.trimPercent)) && runner.responsivenessWindow == nil { + runner.setResponsivenessWindow(intervalIndex) + phaseCancel() + } + } + } + prevIntervalBytes = currentBytes + prevIntervalTime = now + intervalIndex++ + runner.currentInterval.Store(int64(intervalIndex)) + case <-progressTicker.C: + if onProgress != nil { + onProgress(int64(ewmaCapacity), runner.currentRPM.Load()) + } + case <-phaseCtx.Done(): + if ctx.Err() != nil { + return nil, ctx.Err() + } + totalDuration := time.Since(start) + return &directionMeasurement{ + capacity: runner.finalCapacity(totalDuration), + rpm: runner.finalRPM(), + capacityAccuracy: throughputTracker.accuracy(), + rpmAccuracy: responsivenessTracker.accuracy(), + }, nil + } + } +} + +func splitRuntimeBudget(total time.Duration, directions int) (time.Duration, time.Duration) { + if directions <= 1 { + return total, total + } + first := total / time.Duration(directions) + second := total - first + return first, second +} + +func collectProbeRound(ctx context.Context, foreignClient *http.Client, selfClient *http.Client, rawURL string) (probeMeasurement, error) { + var foreignResult probeMeasurement + var selfResult probeMeasurement + var foreignErr error + var selfErr error + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + foreignResult, foreignErr = runProbe(ctx, foreignClient, rawURL, false) + }() + go func() { + defer wg.Done() + selfResult, selfErr = runProbe(ctx, selfClient, rawURL, true) + }() + wg.Wait() + + if foreignErr != nil { + return probeMeasurement{}, E.Cause(foreignErr, "foreign probe") + } + if selfErr != nil { + return probeMeasurement{}, E.Cause(selfErr, "self probe") + } + return probeMeasurement{ + tcp: foreignResult.tcp, + tls: foreignResult.tls, + httpFirst: foreignResult.httpFirst, + httpLoaded: selfResult.httpLoaded, + }, nil +} + +func runProbe(ctx context.Context, client *http.Client, rawURL string, expectReuse bool) (probeMeasurement, error) { + var trace probeTrace + start := time.Now() + req, err := newRequest(httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ + ConnectStart: func(string, string) { + if trace.connectStart.IsZero() { + trace.connectStart = time.Now() + } + }, + ConnectDone: func(string, string, error) { + if trace.connectDone.IsZero() { + trace.connectDone = time.Now() + } + }, + TLSHandshakeStart: func() { + if trace.tlsStart.IsZero() { + trace.tlsStart = time.Now() + } + }, + TLSHandshakeDone: func(state tls.ConnectionState, _ error) { + if trace.tlsDone.IsZero() { + trace.tlsDone = time.Now() + trace.tlsVersion = state.Version + } + }, + GotConn: func(info httptrace.GotConnInfo) { + trace.reused = info.Reused + trace.gotConn = time.Now() + }, + WroteRequest: func(httptrace.WroteRequestInfo) { + trace.wroteRequest = time.Now() + }, + GotFirstResponseByte: func() { + trace.firstResponseByte = time.Now() + }, + }), http.MethodGet, rawURL, nil) + if err != nil { + return probeMeasurement{}, err + } + if !expectReuse { + req.Close = true + } + resp, err := client.Do(req) + if err != nil { + return probeMeasurement{}, err + } + defer resp.Body.Close() + if err = validateResponse(resp); err != nil { + return probeMeasurement{}, err + } + n, err := io.Copy(io.Discard, resp.Body) + end := time.Now() + if err != nil { + return probeMeasurement{}, err + } + if expectReuse && !trace.reused { + return probeMeasurement{}, E.New("self probe did not reuse an existing connection") + } + + httpStart := trace.wroteRequest + if httpStart.IsZero() { + switch { + case !trace.tlsDone.IsZero(): + httpStart = trace.tlsDone + case !trace.connectDone.IsZero(): + httpStart = trace.connectDone + case !trace.gotConn.IsZero(): + httpStart = trace.gotConn + default: + httpStart = start + } + } + + measurement := probeMeasurement{ + total: end.Sub(start), + bytes: n, + reused: trace.reused, + } + if !trace.connectStart.IsZero() && !trace.connectDone.IsZero() && trace.connectDone.After(trace.connectStart) { + measurement.tcp = trace.connectDone.Sub(trace.connectStart) + } + if !trace.tlsStart.IsZero() && !trace.tlsDone.IsZero() && trace.tlsDone.After(trace.tlsStart) { + measurement.tls = trace.tlsDone.Sub(trace.tlsStart) + if roundTrips := tlsHandshakeRoundTrips(trace.tlsVersion); roundTrips > 1 { + measurement.tls /= time.Duration(roundTrips) + } + } + if !trace.firstResponseByte.IsZero() && trace.firstResponseByte.After(httpStart) { + measurement.httpFirst = trace.firstResponseByte.Sub(httpStart) + } + if end.After(httpStart) { + measurement.httpLoaded = end.Sub(httpStart) + } + return measurement, nil +} + +func runDownloadRequest(ctx context.Context, client *http.Client, rawURL string, onActive func()) error { + req, err := newRequest(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + err = validateResponse(resp) + if err != nil { + return err + } + if onActive != nil { + onActive() + } + _, err = sBufio.Copy(io.Discard, resp.Body) + if ctx.Err() != nil { + return nil + } + return err +} + +func runUploadRequest(ctx context.Context, client *http.Client, rawURL string, onActive func()) error { + body := &uploadBody{ + ctx: ctx, + onActive: onActive, + } + req, err := newRequest(ctx, http.MethodPost, rawURL, body) + if err != nil { + return err + } + req.ContentLength = -1 + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + defer resp.Body.Close() + err = validateResponse(resp) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, resp.Body) + <-ctx.Done() + return nil +} + +func newRequest(ctx context.Context, method string, rawURL string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, rawURL, body) + if err != nil { + return nil, err + } + req.Header.Set("Accept-Encoding", "identity") + return req, nil +} + +func validateResponse(resp *http.Response) error { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return E.New("unexpected status: ", resp.Status) + } + if encoding := resp.Header.Get("Content-Encoding"); encoding != "" { + return E.New("unexpected content encoding: ", encoding) + } + return nil +} + +func calculateRPM(rounds []probeRound) int32 { + if len(rounds) == 0 { + return 0 + } + var tcpSamples []float64 + var tlsSamples []float64 + var httpFirstSamples []float64 + var httpLoadedSamples []float64 + for _, round := range rounds { + if round.tcp > 0 { + tcpSamples = append(tcpSamples, durationMillis(round.tcp)) + } + if round.tls > 0 { + tlsSamples = append(tlsSamples, durationMillis(round.tls)) + } + if round.httpFirst > 0 { + httpFirstSamples = append(httpFirstSamples, durationMillis(round.httpFirst)) + } + if round.httpLoaded > 0 { + httpLoadedSamples = append(httpLoadedSamples, durationMillis(round.httpLoaded)) + } + } + httpLoaded := upperTrimmedMean(httpLoadedSamples, settings.trimPercent) + if httpLoaded <= 0 { + return 0 + } + var foreignComponents []float64 + if tcp := upperTrimmedMean(tcpSamples, settings.trimPercent); tcp > 0 { + foreignComponents = append(foreignComponents, tcp) + } + if tls := upperTrimmedMean(tlsSamples, settings.trimPercent); tls > 0 { + foreignComponents = append(foreignComponents, tls) + } + if httpFirst := upperTrimmedMean(httpFirstSamples, settings.trimPercent); httpFirst > 0 { + foreignComponents = append(foreignComponents, httpFirst) + } + if len(foreignComponents) == 0 { + return 0 + } + foreignLatency := meanFloat64s(foreignComponents) + foreignRPM := 60000.0 / foreignLatency + loadedRPM := 60000.0 / httpLoaded + return int32(math.Round((foreignRPM + loadedRPM) / 2)) +} + +func tlsHandshakeRoundTrips(version uint16) int { + switch version { + case tls.VersionTLS12, tls.VersionTLS11, tls.VersionTLS10: + return 2 + default: + return 1 + } +} + +func durationMillis(value time.Duration) float64 { + return float64(value) / float64(time.Millisecond) +} + +func upperTrimmedMean(values []float64, trimPercent int) float64 { + trimmed := upperTrimFloat64s(values, trimPercent) + if len(trimmed) == 0 { + return 0 + } + return meanFloat64s(trimmed) +} + +func upperTrimFloat64s(values []float64, trimPercent int) []float64 { + if len(values) == 0 { + return nil + } + trimmed := append([]float64(nil), values...) + sort.Float64s(trimmed) + if trimPercent <= 0 { + return trimmed + } + trimCount := int(math.Floor(float64(len(trimmed)) * float64(trimPercent) / 100)) + if trimCount <= 0 || trimCount >= len(trimmed) { + return trimmed + } + return trimmed[:len(trimmed)-trimCount] +} + +func meanFloat64s(values []float64) float64 { + if len(values) == 0 { + return 0 + } + var total float64 + for _, value := range values { + total += value + } + return total / float64(len(values)) +} + +func stdDevFloat64s(values []float64) float64 { + if len(values) == 0 { + return 0 + } + mean := meanFloat64s(values) + var total float64 + for _, value := range values { + delta := value - mean + total += delta * delta + } + return math.Sqrt(total / float64(len(values))) +} + +type uploadBody struct { + ctx context.Context + activated atomic.Bool + onActive func() +} + +func (u *uploadBody) Read(p []byte) (int, error) { + if err := u.ctx.Err(); err != nil { + return 0, err + } + clear(p) + n := len(p) + if n > 0 && u.onActive != nil && u.activated.CompareAndSwap(false, true) { + u.onActive() + } + return n, nil +} + +func (u *uploadBody) Close() error { + return nil +} diff --git a/common/pipelistener/listener.go b/common/pipelistener/listener.go new file mode 100644 index 00000000..68de4d90 --- /dev/null +++ b/common/pipelistener/listener.go @@ -0,0 +1,57 @@ +package pipelistener + +import ( + "io" + "net" +) + +var _ net.Listener = (*Listener)(nil) + +type Listener struct { + pipe chan net.Conn + done chan struct{} +} + +func New(channelSize int) *Listener { + return &Listener{ + pipe: make(chan net.Conn, channelSize), + done: make(chan struct{}), + } +} + +func (l *Listener) Serve(conn net.Conn) { + l.pipe <- conn +} + +func (l *Listener) Accept() (net.Conn, error) { + select { + case conn := <-l.pipe: + return conn, nil + case <-l.done: + return nil, io.ErrClosedPipe + } +} + +func (l *Listener) Close() error { + select { + case <-l.done: + return io.ErrClosedPipe + default: + } + close(l.done) + return nil +} + +func (l *Listener) Addr() net.Addr { + return addr{} +} + +type addr struct{} + +func (a addr) Network() string { + return "pipe" +} + +func (a addr) String() string { + return "pipe" +} diff --git a/common/process/searcher.go b/common/process/searcher.go new file mode 100644 index 00000000..64305237 --- /dev/null +++ b/common/process/searcher.go @@ -0,0 +1,39 @@ +package process + +import ( + "context" + "net/netip" + "os/user" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +type Searcher interface { + FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) + Close() error +} + +var ErrNotFound = E.New("process not found") + +type Config struct { + Logger log.ContextLogger + PackageManager tun.PackageManager +} + +func FindProcessInfo(searcher Searcher, ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + info, err := searcher.FindProcessInfo(ctx, network, source, destination) + if err != nil { + return nil, err + } + if info.UserId != -1 && info.UserName == "" { + osUser, _ := user.LookupId(F.ToString(info.UserId)) + if osUser != nil { + info.UserName = osUser.Username + } + } + return info, nil +} diff --git a/common/process/searcher_android.go b/common/process/searcher_android.go new file mode 100644 index 00000000..287c7219 --- /dev/null +++ b/common/process/searcher_android.go @@ -0,0 +1,48 @@ +package process + +import ( + "context" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" +) + +var _ Searcher = (*androidSearcher)(nil) + +type androidSearcher struct { + packageManager tun.PackageManager +} + +func NewSearcher(config Config) (Searcher, error) { + return &androidSearcher{config.PackageManager}, nil +} + +func (s *androidSearcher) Close() error { + return nil +} + +func (s *androidSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + family, protocol, err := socketDiagSettings(network, source) + if err != nil { + return nil, err + } + _, uid, err := querySocketDiagOnce(family, protocol, source) + if err != nil { + return nil, err + } + appID := uid % 100000 + var packageNames []string + if sharedPackage, loaded := s.packageManager.SharedPackageByID(appID); loaded { + packageNames = append(packageNames, sharedPackage) + } + if packages, loaded := s.packageManager.PackagesByID(appID); loaded { + packageNames = append(packageNames, packages...) + } + packageNames = common.Uniq(packageNames) + return &adapter.ConnectionOwner{ + UserId: int32(uid), + AndroidPackageNames: packageNames, + }, nil +} diff --git a/common/process/searcher_darwin.go b/common/process/searcher_darwin.go new file mode 100644 index 00000000..1b5c0dd6 --- /dev/null +++ b/common/process/searcher_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package process + +import ( + "context" + "net/netip" + "strconv" + "strings" + "syscall" + + "github.com/sagernet/sing-box/adapter" +) + +var _ Searcher = (*darwinSearcher)(nil) + +type darwinSearcher struct{} + +func NewSearcher(_ Config) (Searcher, error) { + return &darwinSearcher{}, nil +} + +func (d *darwinSearcher) Close() error { + return nil +} + +func (d *darwinSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + return FindDarwinConnectionOwner(network, source, destination) +} + +var structSize = func() int { + value, _ := syscall.Sysctl("kern.osrelease") + major, _, _ := strings.Cut(value, ".") + n, _ := strconv.ParseInt(major, 10, 64) + switch true { + case n >= 22: + return 408 + default: + // from darwin-xnu/bsd/netinet/in_pcblist.c:get_pcblist_n + // size/offset are round up (aligned) to 8 bytes in darwin + // rup8(sizeof(xinpcb_n)) + rup8(sizeof(xsocket_n)) + + // 2 * rup8(sizeof(xsockbuf_n)) + rup8(sizeof(xsockstat_n)) + return 384 + } +}() diff --git a/common/process/searcher_darwin_shared.go b/common/process/searcher_darwin_shared.go new file mode 100644 index 00000000..05925530 --- /dev/null +++ b/common/process/searcher_darwin_shared.go @@ -0,0 +1,269 @@ +//go:build darwin + +package process + +import ( + "encoding/binary" + "net/netip" + "os" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/sys/unix" +) + +const ( + darwinSnapshotTTL = 200 * time.Millisecond + + darwinXinpgenSize = 24 + darwinXsocketOffset = 104 + darwinXinpcbForeignPort = 16 + darwinXinpcbLocalPort = 18 + darwinXinpcbVFlag = 44 + darwinXinpcbForeignAddr = 48 + darwinXinpcbLocalAddr = 64 + darwinXinpcbIPv4Addr = 12 + darwinXsocketUID = 64 + darwinXsocketLastPID = 68 + darwinTCPExtraStructSize = 208 +) + +type darwinConnectionEntry struct { + localAddr netip.Addr + remoteAddr netip.Addr + localPort uint16 + remotePort uint16 + pid uint32 + uid int32 +} + +type darwinConnectionMatchKind uint8 + +const ( + darwinConnectionMatchExact darwinConnectionMatchKind = iota + darwinConnectionMatchLocalFallback + darwinConnectionMatchWildcardFallback +) + +type darwinSnapshot struct { + createdAt time.Time + entries []darwinConnectionEntry +} + +type darwinConnectionFinder struct { + access sync.Mutex + ttl time.Duration + snapshots map[string]darwinSnapshot + builder func(string) (darwinSnapshot, error) +} + +var sharedDarwinConnectionFinder = newDarwinConnectionFinder(darwinSnapshotTTL) + +func newDarwinConnectionFinder(ttl time.Duration) *darwinConnectionFinder { + return &darwinConnectionFinder{ + ttl: ttl, + snapshots: make(map[string]darwinSnapshot), + builder: buildDarwinSnapshot, + } +} + +func FindDarwinConnectionOwner(network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + return sharedDarwinConnectionFinder.find(network, source, destination) +} + +func (f *darwinConnectionFinder) find(network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + networkName := N.NetworkName(network) + source = normalizeDarwinAddrPort(source) + destination = normalizeDarwinAddrPort(destination) + var lastOwner *adapter.ConnectionOwner + for attempt := 0; attempt < 2; attempt++ { + snapshot, fromCache, err := f.loadSnapshot(networkName, attempt > 0) + if err != nil { + return nil, err + } + entry, matchKind, err := matchDarwinConnectionEntry(snapshot.entries, networkName, source, destination) + if err != nil { + if err == ErrNotFound && fromCache { + continue + } + return nil, err + } + if fromCache && matchKind != darwinConnectionMatchExact { + continue + } + owner := &adapter.ConnectionOwner{ + UserId: entry.uid, + } + lastOwner = owner + if entry.pid == 0 { + return owner, nil + } + processPath, err := getExecPathFromPID(entry.pid) + if err == nil { + owner.ProcessPath = processPath + return owner, nil + } + if fromCache { + continue + } + return owner, nil + } + if lastOwner != nil { + return lastOwner, nil + } + return nil, ErrNotFound +} + +func (f *darwinConnectionFinder) loadSnapshot(network string, forceRefresh bool) (darwinSnapshot, bool, error) { + f.access.Lock() + defer f.access.Unlock() + if !forceRefresh { + if snapshot, loaded := f.snapshots[network]; loaded && time.Since(snapshot.createdAt) < f.ttl { + return snapshot, true, nil + } + } + snapshot, err := f.builder(network) + if err != nil { + return darwinSnapshot{}, false, err + } + f.snapshots[network] = snapshot + return snapshot, false, nil +} + +func buildDarwinSnapshot(network string) (darwinSnapshot, error) { + spath, itemSize, err := darwinSnapshotSettings(network) + if err != nil { + return darwinSnapshot{}, err + } + value, err := unix.SysctlRaw(spath) + if err != nil { + return darwinSnapshot{}, err + } + return darwinSnapshot{ + createdAt: time.Now(), + entries: parseDarwinSnapshot(value, itemSize), + }, nil +} + +func darwinSnapshotSettings(network string) (string, int, error) { + itemSize := structSize + switch network { + case N.NetworkTCP: + return "net.inet.tcp.pcblist_n", itemSize + darwinTCPExtraStructSize, nil + case N.NetworkUDP: + return "net.inet.udp.pcblist_n", itemSize, nil + default: + return "", 0, os.ErrInvalid + } +} + +func parseDarwinSnapshot(buf []byte, itemSize int) []darwinConnectionEntry { + entries := make([]darwinConnectionEntry, 0, (len(buf)-darwinXinpgenSize)/itemSize) + for i := darwinXinpgenSize; i+itemSize <= len(buf); i += itemSize { + inp := i + so := i + darwinXsocketOffset + entry, ok := parseDarwinConnectionEntry(buf[inp:so], buf[so:so+structSize-darwinXsocketOffset]) + if ok { + entries = append(entries, entry) + } + } + return entries +} + +func parseDarwinConnectionEntry(inp []byte, so []byte) (darwinConnectionEntry, bool) { + if len(inp) < darwinXsocketOffset || len(so) < structSize-darwinXsocketOffset { + return darwinConnectionEntry{}, false + } + entry := darwinConnectionEntry{ + remotePort: binary.BigEndian.Uint16(inp[darwinXinpcbForeignPort : darwinXinpcbForeignPort+2]), + localPort: binary.BigEndian.Uint16(inp[darwinXinpcbLocalPort : darwinXinpcbLocalPort+2]), + pid: binary.NativeEndian.Uint32(so[darwinXsocketLastPID : darwinXsocketLastPID+4]), + uid: int32(binary.NativeEndian.Uint32(so[darwinXsocketUID : darwinXsocketUID+4])), + } + flag := inp[darwinXinpcbVFlag] + switch { + case flag&0x1 != 0: + entry.remoteAddr = netip.AddrFrom4([4]byte(inp[darwinXinpcbForeignAddr+darwinXinpcbIPv4Addr : darwinXinpcbForeignAddr+darwinXinpcbIPv4Addr+4])) + entry.localAddr = netip.AddrFrom4([4]byte(inp[darwinXinpcbLocalAddr+darwinXinpcbIPv4Addr : darwinXinpcbLocalAddr+darwinXinpcbIPv4Addr+4])) + return entry, true + case flag&0x2 != 0: + entry.remoteAddr = netip.AddrFrom16([16]byte(inp[darwinXinpcbForeignAddr : darwinXinpcbForeignAddr+16])) + entry.localAddr = netip.AddrFrom16([16]byte(inp[darwinXinpcbLocalAddr : darwinXinpcbLocalAddr+16])) + return entry, true + default: + return darwinConnectionEntry{}, false + } +} + +func matchDarwinConnectionEntry(entries []darwinConnectionEntry, network string, source netip.AddrPort, destination netip.AddrPort) (darwinConnectionEntry, darwinConnectionMatchKind, error) { + sourceAddr := source.Addr() + if !sourceAddr.IsValid() { + return darwinConnectionEntry{}, darwinConnectionMatchExact, os.ErrInvalid + } + var localFallback darwinConnectionEntry + var hasLocalFallback bool + var wildcardFallback darwinConnectionEntry + var hasWildcardFallback bool + for _, entry := range entries { + if entry.localPort != source.Port() || sourceAddr.BitLen() != entry.localAddr.BitLen() { + continue + } + if entry.localAddr == sourceAddr && destination.IsValid() && entry.remotePort == destination.Port() && entry.remoteAddr == destination.Addr() { + return entry, darwinConnectionMatchExact, nil + } + if !destination.IsValid() && entry.localAddr == sourceAddr { + return entry, darwinConnectionMatchExact, nil + } + if network != N.NetworkUDP { + continue + } + if !hasLocalFallback && entry.localAddr == sourceAddr { + hasLocalFallback = true + localFallback = entry + } + if !hasWildcardFallback && entry.localAddr.IsUnspecified() { + hasWildcardFallback = true + wildcardFallback = entry + } + } + if hasLocalFallback { + return localFallback, darwinConnectionMatchLocalFallback, nil + } + if hasWildcardFallback { + return wildcardFallback, darwinConnectionMatchWildcardFallback, nil + } + return darwinConnectionEntry{}, darwinConnectionMatchExact, ErrNotFound +} + +func normalizeDarwinAddrPort(addrPort netip.AddrPort) netip.AddrPort { + if !addrPort.IsValid() { + return addrPort + } + return netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port()) +} + +func getExecPathFromPID(pid uint32) (string, error) { + const ( + procpidpathinfo = 0xb + procpidpathinfosize = 1024 + proccallnumpidinfo = 0x2 + ) + buf := make([]byte, procpidpathinfosize) + _, _, errno := syscall.Syscall6( + syscall.SYS_PROC_INFO, + proccallnumpidinfo, + uintptr(pid), + procpidpathinfo, + 0, + uintptr(unsafe.Pointer(&buf[0])), + procpidpathinfosize) + if errno != 0 { + return "", errno + } + return unix.ByteSliceToString(buf), nil +} diff --git a/common/process/searcher_linux.go b/common/process/searcher_linux.go new file mode 100644 index 00000000..9b1a9160 --- /dev/null +++ b/common/process/searcher_linux.go @@ -0,0 +1,85 @@ +//go:build linux && !android + +package process + +import ( + "context" + "errors" + "net/netip" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ Searcher = (*linuxSearcher)(nil) + +type linuxSearcher struct { + logger log.ContextLogger + diagConns [4]*socketDiagConn + processPathCache *uidProcessPathCache +} + +func NewSearcher(config Config) (Searcher, error) { + searcher := &linuxSearcher{ + logger: config.Logger, + processPathCache: newUIDProcessPathCache(time.Second), + } + for _, family := range []uint8{syscall.AF_INET, syscall.AF_INET6} { + for _, protocol := range []uint8{syscall.IPPROTO_TCP, syscall.IPPROTO_UDP} { + searcher.diagConns[socketDiagConnIndex(family, protocol)] = newSocketDiagConn(family, protocol) + } + } + return searcher, nil +} + +func (s *linuxSearcher) Close() error { + var errs []error + for _, conn := range s.diagConns { + if conn == nil { + continue + } + errs = append(errs, conn.Close()) + } + return E.Errors(errs...) +} + +func (s *linuxSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + inode, uid, err := s.resolveSocketByNetlink(network, source, destination) + if err != nil { + return nil, err + } + processInfo := &adapter.ConnectionOwner{ + UserId: int32(uid), + } + processPath, err := s.processPathCache.findProcessPath(inode, uid) + if err != nil { + s.logger.DebugContext(ctx, "find process path: ", err) + } else { + processInfo.ProcessPath = processPath + } + return processInfo, nil +} + +func (s *linuxSearcher) resolveSocketByNetlink(network string, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + family, protocol, err := socketDiagSettings(network, source) + if err != nil { + return 0, 0, err + } + conn := s.diagConns[socketDiagConnIndex(family, protocol)] + if conn == nil { + return 0, 0, E.New("missing socket diag connection for family=", family, " protocol=", protocol) + } + if destination.IsValid() && source.Addr().BitLen() == destination.Addr().BitLen() { + inode, uid, err = conn.query(source, destination) + if err == nil { + return inode, uid, nil + } + if !errors.Is(err, ErrNotFound) { + return 0, 0, err + } + } + return querySocketDiagOnce(family, protocol, source) +} diff --git a/common/process/searcher_linux_shared.go b/common/process/searcher_linux_shared.go new file mode 100644 index 00000000..cd0601bc --- /dev/null +++ b/common/process/searcher_linux_shared.go @@ -0,0 +1,383 @@ +//go:build linux + +package process + +import ( + "encoding/binary" + "errors" + "net/netip" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + "unicode" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" +) + +const ( + sizeOfSocketDiagRequestData = 56 + sizeOfSocketDiagRequest = syscall.SizeofNlMsghdr + sizeOfSocketDiagRequestData + socketDiagResponseMinSize = 72 + socketDiagByFamily = 20 + pathProc = "/proc" +) + +type socketDiagConn struct { + access sync.Mutex + family uint8 + protocol uint8 + fd int +} + +type uidProcessPathCache struct { + cache freelru.Cache[uint32, *uidProcessPaths] +} + +type uidProcessPaths struct { + entries map[uint32]string +} + +func newSocketDiagConn(family, protocol uint8) *socketDiagConn { + return &socketDiagConn{ + family: family, + protocol: protocol, + fd: -1, + } +} + +func socketDiagConnIndex(family, protocol uint8) int { + index := 0 + if protocol == syscall.IPPROTO_UDP { + index += 2 + } + if family == syscall.AF_INET6 { + index++ + } + return index +} + +func socketDiagSettings(network string, source netip.AddrPort) (family, protocol uint8, err error) { + switch network { + case N.NetworkTCP: + protocol = syscall.IPPROTO_TCP + case N.NetworkUDP: + protocol = syscall.IPPROTO_UDP + default: + return 0, 0, os.ErrInvalid + } + switch { + case source.Addr().Is4(): + family = syscall.AF_INET + case source.Addr().Is6(): + family = syscall.AF_INET6 + default: + return 0, 0, os.ErrInvalid + } + return family, protocol, nil +} + +func newUIDProcessPathCache(ttl time.Duration) *uidProcessPathCache { + cache := common.Must1(freelru.NewSharded[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32)) + cache.SetLifetime(ttl) + return &uidProcessPathCache{cache: cache} +} + +func (c *uidProcessPathCache) findProcessPath(targetInode, uid uint32) (string, error) { + if cached, ok := c.cache.Get(uid); ok { + if processPath, found := cached.entries[targetInode]; found { + return processPath, nil + } + } + processPaths, err := buildProcessPathByUIDCache(uid) + if err != nil { + return "", err + } + c.cache.Add(uid, &uidProcessPaths{entries: processPaths}) + processPath, found := processPaths[targetInode] + if !found { + return "", E.New("process of uid(", uid, "), inode(", targetInode, ") not found") + } + return processPath, nil +} + +func (c *socketDiagConn) Close() error { + c.access.Lock() + defer c.access.Unlock() + return c.closeLocked() +} + +func (c *socketDiagConn) query(source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + c.access.Lock() + defer c.access.Unlock() + request := packSocketDiagRequest(c.family, c.protocol, source, destination, false) + for attempt := 0; attempt < 2; attempt++ { + err = c.ensureOpenLocked() + if err != nil { + return 0, 0, E.Cause(err, "dial netlink") + } + inode, uid, err = querySocketDiag(c.fd, request) + if err == nil || errors.Is(err, ErrNotFound) { + return inode, uid, err + } + if !shouldRetrySocketDiag(err) { + return 0, 0, err + } + _ = c.closeLocked() + } + return 0, 0, err +} + +func querySocketDiagOnce(family, protocol uint8, source netip.AddrPort) (inode, uid uint32, err error) { + fd, err := openSocketDiag() + if err != nil { + return 0, 0, E.Cause(err, "dial netlink") + } + defer syscall.Close(fd) + return querySocketDiag(fd, packSocketDiagRequest(family, protocol, source, netip.AddrPort{}, true)) +} + +func (c *socketDiagConn) ensureOpenLocked() error { + if c.fd != -1 { + return nil + } + fd, err := openSocketDiag() + if err != nil { + return err + } + c.fd = fd + return nil +} + +func openSocketDiag() (int, error) { + fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_DGRAM|syscall.SOCK_CLOEXEC, syscall.NETLINK_INET_DIAG) + if err != nil { + return -1, err + } + timeout := &syscall.Timeval{Usec: 100} + if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, timeout); err != nil { + syscall.Close(fd) + return -1, err + } + if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, timeout); err != nil { + syscall.Close(fd) + return -1, err + } + if err = syscall.Connect(fd, &syscall.SockaddrNetlink{ + Family: syscall.AF_NETLINK, + Pid: 0, + Groups: 0, + }); err != nil { + syscall.Close(fd) + return -1, err + } + return fd, nil +} + +func (c *socketDiagConn) closeLocked() error { + if c.fd == -1 { + return nil + } + err := syscall.Close(c.fd) + c.fd = -1 + return err +} + +func packSocketDiagRequest(family, protocol byte, source netip.AddrPort, destination netip.AddrPort, dump bool) []byte { + request := make([]byte, sizeOfSocketDiagRequest) + + binary.NativeEndian.PutUint32(request[0:4], sizeOfSocketDiagRequest) + binary.NativeEndian.PutUint16(request[4:6], socketDiagByFamily) + flags := uint16(syscall.NLM_F_REQUEST) + if dump { + flags |= syscall.NLM_F_DUMP + } + binary.NativeEndian.PutUint16(request[6:8], flags) + binary.NativeEndian.PutUint32(request[8:12], 0) + binary.NativeEndian.PutUint32(request[12:16], 0) + + request[16] = family + request[17] = protocol + request[18] = 0 + request[19] = 0 + if dump { + binary.NativeEndian.PutUint32(request[20:24], 0xFFFFFFFF) + } + requestSource := source + requestDestination := destination + if protocol == syscall.IPPROTO_UDP && !dump && destination.IsValid() { + // udp_dump_one expects the exact-match endpoints reversed for historical reasons. + requestSource, requestDestination = destination, source + } + binary.BigEndian.PutUint16(request[24:26], requestSource.Port()) + binary.BigEndian.PutUint16(request[26:28], requestDestination.Port()) + if family == syscall.AF_INET6 { + copy(request[28:44], requestSource.Addr().AsSlice()) + if requestDestination.IsValid() { + copy(request[44:60], requestDestination.Addr().AsSlice()) + } + } else { + copy(request[28:32], requestSource.Addr().AsSlice()) + if requestDestination.IsValid() { + copy(request[44:48], requestDestination.Addr().AsSlice()) + } + } + binary.NativeEndian.PutUint32(request[60:64], 0) + binary.NativeEndian.PutUint64(request[64:72], 0xFFFFFFFFFFFFFFFF) + return request +} + +func querySocketDiag(fd int, request []byte) (inode, uid uint32, err error) { + _, err = syscall.Write(fd, request) + if err != nil { + return 0, 0, E.Cause(err, "write netlink request") + } + buffer := make([]byte, 64<<10) + n, err := syscall.Read(fd, buffer) + if err != nil { + return 0, 0, E.Cause(err, "read netlink response") + } + messages, err := syscall.ParseNetlinkMessage(buffer[:n]) + if err != nil { + return 0, 0, E.Cause(err, "parse netlink message") + } + return unpackSocketDiagMessages(messages) +} + +func unpackSocketDiagMessages(messages []syscall.NetlinkMessage) (inode, uid uint32, err error) { + for _, message := range messages { + switch message.Header.Type { + case syscall.NLMSG_DONE: + continue + case syscall.NLMSG_ERROR: + err = unpackSocketDiagError(&message) + if err != nil { + return 0, 0, err + } + case socketDiagByFamily: + inode, uid = unpackSocketDiagResponse(&message) + if inode != 0 || uid != 0 { + return inode, uid, nil + } + } + } + return 0, 0, ErrNotFound +} + +func unpackSocketDiagResponse(msg *syscall.NetlinkMessage) (inode, uid uint32) { + if len(msg.Data) < socketDiagResponseMinSize { + return 0, 0 + } + uid = binary.NativeEndian.Uint32(msg.Data[64:68]) + inode = binary.NativeEndian.Uint32(msg.Data[68:72]) + return inode, uid +} + +func unpackSocketDiagError(msg *syscall.NetlinkMessage) error { + if len(msg.Data) < 4 { + return E.New("netlink message: NLMSG_ERROR") + } + errno := int32(binary.NativeEndian.Uint32(msg.Data[:4])) + if errno == 0 { + return nil + } + if errno < 0 { + errno = -errno + } + sysErr := syscall.Errno(errno) + switch sysErr { + case syscall.ENOENT, syscall.ESRCH: + return ErrNotFound + default: + return E.New("netlink message: ", sysErr) + } +} + +func shouldRetrySocketDiag(err error) bool { + return err != nil && !errors.Is(err, ErrNotFound) +} + +func buildProcessPathByUIDCache(uid uint32) (map[uint32]string, error) { + files, err := os.ReadDir(pathProc) + if err != nil { + return nil, err + } + buffer := make([]byte, syscall.PathMax) + processPaths := make(map[uint32]string) + for _, file := range files { + if !file.IsDir() || !isPid(file.Name()) { + continue + } + info, err := file.Info() + if err != nil { + if isIgnorableProcError(err) { + continue + } + return nil, err + } + if info.Sys().(*syscall.Stat_t).Uid != uid { + continue + } + processPath := filepath.Join(pathProc, file.Name()) + fdPath := filepath.Join(processPath, "fd") + exePath, err := os.Readlink(filepath.Join(processPath, "exe")) + if err != nil { + if isIgnorableProcError(err) { + continue + } + return nil, err + } + fds, err := os.ReadDir(fdPath) + if err != nil { + continue + } + for _, fd := range fds { + n, err := syscall.Readlink(filepath.Join(fdPath, fd.Name()), buffer) + if err != nil { + continue + } + inode, ok := parseSocketInode(buffer[:n]) + if !ok { + continue + } + if _, loaded := processPaths[inode]; !loaded { + processPaths[inode] = exePath + } + } + } + return processPaths, nil +} + +func isIgnorableProcError(err error) bool { + return os.IsNotExist(err) || os.IsPermission(err) +} + +func parseSocketInode(link []byte) (uint32, bool) { + const socketPrefix = "socket:[" + if len(link) <= len(socketPrefix) || string(link[:len(socketPrefix)]) != socketPrefix || link[len(link)-1] != ']' { + return 0, false + } + var inode uint64 + for _, char := range link[len(socketPrefix) : len(link)-1] { + if char < '0' || char > '9' { + return 0, false + } + inode = inode*10 + uint64(char-'0') + if inode > uint64(^uint32(0)) { + return 0, false + } + } + return uint32(inode), true +} + +func isPid(s string) bool { + return strings.IndexFunc(s, func(r rune) bool { + return !unicode.IsDigit(r) + }) == -1 +} diff --git a/common/process/searcher_linux_shared_test.go b/common/process/searcher_linux_shared_test.go new file mode 100644 index 00000000..1befff4e --- /dev/null +++ b/common/process/searcher_linux_shared_test.go @@ -0,0 +1,60 @@ +//go:build linux + +package process + +import ( + "net" + "net/netip" + "os" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestQuerySocketDiagUDPExact(t *testing.T) { + t.Parallel() + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer server.Close() + + client, err := net.DialUDP("udp4", nil, server.LocalAddr().(*net.UDPAddr)) + require.NoError(t, err) + defer client.Close() + + err = client.SetDeadline(time.Now().Add(time.Second)) + require.NoError(t, err) + _, err = client.Write([]byte{0}) + require.NoError(t, err) + + err = server.SetReadDeadline(time.Now().Add(time.Second)) + require.NoError(t, err) + buffer := make([]byte, 1) + _, _, err = server.ReadFromUDP(buffer) + require.NoError(t, err) + + source := addrPortFromUDPAddr(t, client.LocalAddr()) + destination := addrPortFromUDPAddr(t, client.RemoteAddr()) + + fd, err := openSocketDiag() + require.NoError(t, err) + defer syscall.Close(fd) + + inode, uid, err := querySocketDiag(fd, packSocketDiagRequest(syscall.AF_INET, syscall.IPPROTO_UDP, source, destination, false)) + require.NoError(t, err) + require.NotZero(t, inode) + require.EqualValues(t, os.Getuid(), uid) +} + +func addrPortFromUDPAddr(t *testing.T, addr net.Addr) netip.AddrPort { + t.Helper() + + udpAddr, ok := addr.(*net.UDPAddr) + require.True(t, ok) + + ip, ok := netip.AddrFromSlice(udpAddr.IP) + require.True(t, ok) + + return netip.AddrPortFrom(ip.Unmap(), uint16(udpAddr.Port)) +} diff --git a/common/process/searcher_stub.go b/common/process/searcher_stub.go new file mode 100644 index 00000000..4665d91f --- /dev/null +++ b/common/process/searcher_stub.go @@ -0,0 +1,11 @@ +//go:build !linux && !windows && !darwin + +package process + +import ( + "os" +) + +func NewSearcher(_ Config) (Searcher, error) { + return nil, os.ErrInvalid +} diff --git a/common/process/searcher_windows.go b/common/process/searcher_windows.go new file mode 100644 index 00000000..39695355 --- /dev/null +++ b/common/process/searcher_windows.go @@ -0,0 +1,66 @@ +package process + +import ( + "context" + "net/netip" + "syscall" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/winiphlpapi" + + "golang.org/x/sys/windows" +) + +var _ Searcher = (*windowsSearcher)(nil) + +type windowsSearcher struct{} + +func NewSearcher(_ Config) (Searcher, error) { + err := initWin32API() + if err != nil { + return nil, E.Cause(err, "init win32 api") + } + return &windowsSearcher{}, nil +} + +func initWin32API() error { + return winiphlpapi.LoadExtendedTable() +} + +func (s *windowsSearcher) Close() error { + return nil +} + +func (s *windowsSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + pid, err := winiphlpapi.FindPid(network, source) + if err != nil { + return nil, err + } + path, err := getProcessPath(pid) + if err != nil { + return &adapter.ConnectionOwner{ProcessID: pid, UserId: -1}, err + } + return &adapter.ConnectionOwner{ProcessID: pid, ProcessPath: path, UserId: -1}, nil +} + +func getProcessPath(pid uint32) (string, error) { + switch pid { + case 0: + return ":System Idle Process", nil + case 4: + return ":System", nil + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return "", err + } + defer windows.CloseHandle(handle) + size := uint32(syscall.MAX_LONG_PATH) + buf := make([]uint16, syscall.MAX_LONG_PATH) + err = windows.QueryFullProcessImageName(handle, 0, &buf[0], &size) + if err != nil { + return "", err + } + return windows.UTF16ToString(buf[:size]), nil +} diff --git a/common/redir/redir_darwin.go b/common/redir/redir_darwin.go new file mode 100644 index 00000000..d8691234 --- /dev/null +++ b/common/redir/redir_darwin.go @@ -0,0 +1,64 @@ +package redir + +import ( + "net" + "net/netip" + "syscall" + "unsafe" + + M "github.com/sagernet/sing/common/metadata" +) + +const ( + PF_OUT = 0x2 + DIOCNATLOOK = 0xc0544417 +) + +func GetOriginalDestination(conn net.Conn) (destination netip.AddrPort, err error) { + fd, err := syscall.Open("/dev/pf", 0, syscall.O_RDONLY) + if err != nil { + return netip.AddrPort{}, err + } + defer syscall.Close(fd) + nl := struct { + saddr, daddr, rsaddr, rdaddr [16]byte + sxport, dxport, rsxport, rdxport [4]byte + af, proto, protoVariant, direction uint8 + }{ + af: syscall.AF_INET, + proto: syscall.IPPROTO_TCP, + direction: PF_OUT, + } + la := conn.LocalAddr().(*net.TCPAddr) + ra := conn.RemoteAddr().(*net.TCPAddr) + raIP, laIP := ra.IP, la.IP + raPort, laPort := ra.Port, la.Port + switch { + case raIP.To4() != nil: + copy(nl.saddr[:net.IPv4len], raIP.To4()) + copy(nl.daddr[:net.IPv4len], laIP.To4()) + nl.af = syscall.AF_INET + default: + copy(nl.saddr[:], raIP.To16()) + copy(nl.daddr[:], laIP.To16()) + nl.af = syscall.AF_INET6 + } + nl.sxport[0], nl.sxport[1] = byte(raPort>>8), byte(raPort) + nl.dxport[0], nl.dxport[1] = byte(laPort>>8), byte(laPort) + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), DIOCNATLOOK, uintptr(unsafe.Pointer(&nl))); errno != 0 { + return netip.AddrPort{}, errno + } + + var ip net.IP + switch nl.af { + case syscall.AF_INET: + ip = make(net.IP, net.IPv4len) + copy(ip, nl.rdaddr[:net.IPv4len]) + case syscall.AF_INET6: + ip = make(net.IP, net.IPv6len) + copy(ip, nl.rdaddr[:]) + } + port := uint16(nl.rdxport[0])<<8 | uint16(nl.rdxport[1]) + destination = netip.AddrPortFrom(M.AddrFromIP(ip), port) + return +} diff --git a/common/redir/redir_linux.go b/common/redir/redir_linux.go new file mode 100644 index 00000000..5f61bb2a --- /dev/null +++ b/common/redir/redir_linux.go @@ -0,0 +1,40 @@ +package redir + +import ( + "encoding/binary" + "net" + "net/netip" + "os" + "syscall" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + M "github.com/sagernet/sing/common/metadata" +) + +func GetOriginalDestination(conn net.Conn) (destination netip.AddrPort, err error) { + syscallConn, ok := common.Cast[syscall.Conn](conn) + if !ok { + return netip.AddrPort{}, os.ErrInvalid + } + err = control.Conn(syscallConn, func(fd uintptr) error { + const SO_ORIGINAL_DST = 80 + if conn.RemoteAddr().(*net.TCPAddr).IP.To4() != nil { + raw, err := syscall.GetsockoptIPv6Mreq(int(fd), syscall.IPPROTO_IP, SO_ORIGINAL_DST) + if err != nil { + return err + } + destination = netip.AddrPortFrom(M.AddrFromIP(raw.Multiaddr[4:8]), uint16(raw.Multiaddr[2])<<8+uint16(raw.Multiaddr[3])) + } else { + raw, err := syscall.GetsockoptIPv6MTUInfo(int(fd), syscall.IPPROTO_IPV6, SO_ORIGINAL_DST) + if err != nil { + return err + } + var port [2]byte + binary.BigEndian.PutUint16(port[:], raw.Addr.Port) + destination = netip.AddrPortFrom(M.AddrFromIP(raw.Addr.Addr[:]), binary.LittleEndian.Uint16(port[:])) + } + return nil + }) + return +} diff --git a/common/redir/redir_other.go b/common/redir/redir_other.go new file mode 100644 index 00000000..3d60afeb --- /dev/null +++ b/common/redir/redir_other.go @@ -0,0 +1,13 @@ +//go:build !linux && !darwin + +package redir + +import ( + "net" + "net/netip" + "os" +) + +func GetOriginalDestination(conn net.Conn) (destination netip.AddrPort, err error) { + return netip.AddrPort{}, os.ErrInvalid +} diff --git a/common/redir/tproxy_linux.go b/common/redir/tproxy_linux.go new file mode 100644 index 00000000..a3ccade0 --- /dev/null +++ b/common/redir/tproxy_linux.go @@ -0,0 +1,59 @@ +package redir + +import ( + "encoding/binary" + "net/netip" + "syscall" + + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + + "golang.org/x/sys/unix" +) + +func TProxy(fd uintptr, isIPv6 bool, isUDP bool) error { + err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + if err == nil { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_IP, syscall.IP_TRANSPARENT, 1) + } + if err == nil && isIPv6 { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_IPV6, unix.IPV6_TRANSPARENT, 1) + } + if isUDP { + if err == nil { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_IP, syscall.IP_RECVORIGDSTADDR, 1) + } + if err == nil && isIPv6 { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_IPV6, unix.IPV6_RECVORIGDSTADDR, 1) + } + } + return err +} + +func TProxyWriteBack() control.Func { + return func(network, address string, conn syscall.RawConn) error { + return control.Raw(conn, func(fd uintptr) error { + if M.ParseSocksaddr(address).Addr.Is6() { + return syscall.SetsockoptInt(int(fd), syscall.SOL_IPV6, unix.IPV6_TRANSPARENT, 1) + } else { + return syscall.SetsockoptInt(int(fd), syscall.SOL_IP, syscall.IP_TRANSPARENT, 1) + } + }) + } +} + +func GetOriginalDestinationFromOOB(oob []byte) (netip.AddrPort, error) { + controlMessages, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return netip.AddrPort{}, err + } + for _, message := range controlMessages { + if message.Header.Level == unix.SOL_IP && message.Header.Type == unix.IP_RECVORIGDSTADDR { + return netip.AddrPortFrom(M.AddrFromIP(message.Data[4:8]), binary.BigEndian.Uint16(message.Data[2:4])), nil + } else if message.Header.Level == unix.SOL_IPV6 && message.Header.Type == unix.IPV6_RECVORIGDSTADDR { + return netip.AddrPortFrom(M.AddrFromIP(message.Data[8:24]), binary.BigEndian.Uint16(message.Data[2:4])), nil + } + } + return netip.AddrPort{}, E.New("not found") +} diff --git a/common/redir/tproxy_other.go b/common/redir/tproxy_other.go new file mode 100644 index 00000000..42e31fee --- /dev/null +++ b/common/redir/tproxy_other.go @@ -0,0 +1,22 @@ +//go:build !linux + +package redir + +import ( + "net/netip" + "os" + + "github.com/sagernet/sing/common/control" +) + +func TProxy(fd uintptr, isIPv6 bool, isUDP bool) error { + return os.ErrInvalid +} + +func TProxyWriteBack() control.Func { + return nil +} + +func GetOriginalDestinationFromOOB(oob []byte) (netip.AddrPort, error) { + return netip.AddrPort{}, os.ErrInvalid +} diff --git a/common/settings/proxy_android.go b/common/settings/proxy_android.go new file mode 100644 index 00000000..d3768ced --- /dev/null +++ b/common/settings/proxy_android.go @@ -0,0 +1,73 @@ +package settings + +import ( + "context" + "os" + "strings" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/shell" +) + +type AndroidSystemProxy struct { + useRish bool + rishPath string + serverAddr M.Socksaddr + supportSOCKS bool + isEnabled bool +} + +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*AndroidSystemProxy, error) { + userId := os.Getuid() + var ( + useRish bool + rishPath string + ) + if userId == 0 || userId == 1000 || userId == 2000 { + useRish = false + } else { + rishPath, useRish = C.FindPath("rish") + if !useRish { + return nil, E.Cause(os.ErrPermission, "root or system (adb) permission is required for set system proxy") + } + } + return &AndroidSystemProxy{ + useRish: useRish, + rishPath: rishPath, + serverAddr: serverAddr, + supportSOCKS: supportSOCKS, + }, nil +} + +func (p *AndroidSystemProxy) IsEnabled() bool { + return p.isEnabled +} + +func (p *AndroidSystemProxy) Enable() error { + err := p.runAndroidShell("settings", "put", "global", "http_proxy", p.serverAddr.String()) + if err != nil { + return err + } + p.isEnabled = true + return nil +} + +func (p *AndroidSystemProxy) Disable() error { + err := p.runAndroidShell("settings", "put", "global", "http_proxy", ":0") + if err != nil { + return err + } + p.isEnabled = false + return nil +} + +func (p *AndroidSystemProxy) runAndroidShell(name string, args ...string) error { + if !p.useRish { + return shell.Exec(name, args...).Attach().Run() + } else { + return shell.Exec("sh", p.rishPath, "-c", F.ToString(name, " ", strings.Join(args, " "))).Attach().Run() + } +} diff --git a/common/settings/proxy_darwin.go b/common/settings/proxy_darwin.go new file mode 100644 index 00000000..53ed0fe0 --- /dev/null +++ b/common/settings/proxy_darwin.go @@ -0,0 +1,121 @@ +package settings + +import ( + "context" + "strconv" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/shell" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" +) + +type DarwinSystemProxy struct { + monitor tun.DefaultInterfaceMonitor + interfaceName string + element *list.Element[tun.DefaultInterfaceUpdateCallback] + serverAddr M.Socksaddr + supportSOCKS bool + isEnabled bool +} + +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*DarwinSystemProxy, error) { + interfaceMonitor := service.FromContext[adapter.NetworkManager](ctx).InterfaceMonitor() + if interfaceMonitor == nil { + return nil, E.New("missing interface monitor") + } + proxy := &DarwinSystemProxy{ + monitor: interfaceMonitor, + serverAddr: serverAddr, + supportSOCKS: supportSOCKS, + } + proxy.element = interfaceMonitor.RegisterCallback(proxy.routeUpdate) + return proxy, nil +} + +func (p *DarwinSystemProxy) IsEnabled() bool { + return p.isEnabled +} + +func (p *DarwinSystemProxy) Enable() error { + return p.update0() +} + +func (p *DarwinSystemProxy) Disable() error { + interfaceDisplayName, err := getInterfaceDisplayName(p.interfaceName) + if err != nil { + return err + } + if p.supportSOCKS { + err = shell.Exec("networksetup", "-setsocksfirewallproxystate", interfaceDisplayName, "off").Attach().Run() + } + if err == nil { + err = shell.Exec("networksetup", "-setwebproxystate", interfaceDisplayName, "off").Attach().Run() + } + if err == nil { + err = shell.Exec("networksetup", "-setsecurewebproxystate", interfaceDisplayName, "off").Attach().Run() + } + if err == nil { + p.isEnabled = false + } + return err +} + +func (p *DarwinSystemProxy) routeUpdate(defaultInterface *control.Interface, flags int) { + if !p.isEnabled || defaultInterface == nil { + return + } + _ = p.update0() +} + +func (p *DarwinSystemProxy) update0() error { + newInterface := p.monitor.DefaultInterface() + if p.interfaceName == newInterface.Name { + return nil + } + if p.interfaceName != "" { + _ = p.Disable() + } + p.interfaceName = newInterface.Name + interfaceDisplayName, err := getInterfaceDisplayName(p.interfaceName) + if err != nil { + return err + } + if p.supportSOCKS { + err = shell.Exec("networksetup", "-setsocksfirewallproxy", interfaceDisplayName, p.serverAddr.AddrString(), strconv.Itoa(int(p.serverAddr.Port))).Attach().Run() + } + if err != nil { + return err + } + err = shell.Exec("networksetup", "-setwebproxy", interfaceDisplayName, p.serverAddr.AddrString(), strconv.Itoa(int(p.serverAddr.Port))).Attach().Run() + if err != nil { + return err + } + err = shell.Exec("networksetup", "-setsecurewebproxy", interfaceDisplayName, p.serverAddr.AddrString(), strconv.Itoa(int(p.serverAddr.Port))).Attach().Run() + if err != nil { + return err + } + p.isEnabled = true + return nil +} + +func getInterfaceDisplayName(name string) (string, error) { + content, err := shell.Exec("networksetup", "-listallhardwareports").ReadOutput() + if err != nil { + return "", err + } + for _, deviceSpan := range strings.Split(string(content), "Ethernet Address") { + if strings.Contains(deviceSpan, "Device: "+name) { + substr := "Hardware Port: " + deviceSpan = deviceSpan[strings.Index(deviceSpan, substr)+len(substr):] + deviceSpan = deviceSpan[:strings.Index(deviceSpan, "\n")] + return deviceSpan, nil + } + } + return "", E.New(name, " not found in networksetup -listallhardwareports") +} diff --git a/common/settings/proxy_linux.go b/common/settings/proxy_linux.go new file mode 100644 index 00000000..84e27b12 --- /dev/null +++ b/common/settings/proxy_linux.go @@ -0,0 +1,176 @@ +//go:build linux && !android + +package settings + +import ( + "context" + "os" + "os/exec" + "strings" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/shell" +) + +type LinuxSystemProxy struct { + hasGSettings bool + kWriteConfigCmd string + sudoUser string + serverAddr M.Socksaddr + supportSOCKS bool + isEnabled bool +} + +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*LinuxSystemProxy, error) { + hasGSettings := common.Error(exec.LookPath("gsettings")) == nil + kWriteConfigCmds := []string{ + "kwriteconfig5", + "kwriteconfig6", + } + var kWriteConfigCmd string + for _, cmd := range kWriteConfigCmds { + if common.Error(exec.LookPath(cmd)) == nil { + kWriteConfigCmd = cmd + break + } + } + var sudoUser string + if os.Getuid() == 0 { + sudoUser = os.Getenv("SUDO_USER") + } + if !hasGSettings && kWriteConfigCmd == "" { + return nil, E.New("unsupported desktop environment") + } + return &LinuxSystemProxy{ + hasGSettings: hasGSettings, + kWriteConfigCmd: kWriteConfigCmd, + sudoUser: sudoUser, + serverAddr: serverAddr, + supportSOCKS: supportSOCKS, + }, nil +} + +func (p *LinuxSystemProxy) IsEnabled() bool { + return p.isEnabled +} + +func (p *LinuxSystemProxy) Enable() error { + if p.hasGSettings { + err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy.http", "enabled", "true") + if err != nil { + return err + } + if p.supportSOCKS { + err = p.setGnomeProxy("ftp", "http", "https", "socks") + } else { + err = p.setGnomeProxy("http", "https") + } + if err != nil { + return err + } + err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "use-same-proxy", F.ToString(p.supportSOCKS)) + if err != nil { + return err + } + err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "mode", "manual") + if err != nil { + return err + } + } + if p.kWriteConfigCmd != "" { + err := p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + if err != nil { + return err + } + if p.supportSOCKS { + err = p.setKDEProxy("ftp", "http", "https", "socks") + } else { + err = p.setKDEProxy("http", "https") + } + if err != nil { + return err + } + err = p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Authmode", "0") + if err != nil { + return err + } + err = p.runAsUser("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") + if err != nil { + return err + } + } + p.isEnabled = true + return nil +} + +func (p *LinuxSystemProxy) Disable() error { + if p.hasGSettings { + err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "mode", "none") + if err != nil { + return err + } + } + if p.kWriteConfigCmd != "" { + err := p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") + if err != nil { + return err + } + err = p.runAsUser("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") + if err != nil { + return err + } + } + p.isEnabled = false + return nil +} + +func (p *LinuxSystemProxy) runAsUser(name string, args ...string) error { + if os.Getuid() != 0 { + return shell.Exec(name, args...).Attach().Run() + } else if p.sudoUser != "" { + return shell.Exec("su", "-", p.sudoUser, "-c", F.ToString(name, " ", strings.Join(args, " "))).Attach().Run() + } else { + return E.New("set system proxy: unable to set as root") + } +} + +func (p *LinuxSystemProxy) setGnomeProxy(proxyTypes ...string) error { + for _, proxyType := range proxyTypes { + err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy."+proxyType, "host", p.serverAddr.AddrString()) + if err != nil { + return err + } + err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy."+proxyType, "port", F.ToString(p.serverAddr.Port)) + if err != nil { + return err + } + } + return nil +} + +func (p *LinuxSystemProxy) setKDEProxy(proxyTypes ...string) error { + for _, proxyType := range proxyTypes { + var proxyUrl string + if proxyType == "socks" { + proxyUrl = "socks://" + p.serverAddr.String() + } else { + proxyUrl = "http://" + p.serverAddr.String() + } + err := p.runAsUser( + p.kWriteConfigCmd, + "--file", + "kioslaverc", + "--group", + "Proxy Settings", + "--key", proxyType+"Proxy", + proxyUrl, + ) + if err != nil { + return err + } + } + return nil +} diff --git a/common/settings/proxy_stub.go b/common/settings/proxy_stub.go new file mode 100644 index 00000000..56eb4b65 --- /dev/null +++ b/common/settings/proxy_stub.go @@ -0,0 +1,14 @@ +//go:build !(windows || linux || darwin) + +package settings + +import ( + "context" + "os" + + M "github.com/sagernet/sing/common/metadata" +) + +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (SystemProxy, error) { + return nil, os.ErrInvalid +} diff --git a/common/settings/proxy_windows.go b/common/settings/proxy_windows.go new file mode 100644 index 00000000..793ac1d1 --- /dev/null +++ b/common/settings/proxy_windows.go @@ -0,0 +1,43 @@ +package settings + +import ( + "context" + + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/wininet" +) + +type WindowsSystemProxy struct { + serverAddr M.Socksaddr + supportSOCKS bool + isEnabled bool +} + +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*WindowsSystemProxy, error) { + return &WindowsSystemProxy{ + serverAddr: serverAddr, + supportSOCKS: supportSOCKS, + }, nil +} + +func (p *WindowsSystemProxy) IsEnabled() bool { + return p.isEnabled +} + +func (p *WindowsSystemProxy) Enable() error { + err := wininet.SetSystemProxy("http://"+p.serverAddr.String(), "") + if err != nil { + return err + } + p.isEnabled = true + return nil +} + +func (p *WindowsSystemProxy) Disable() error { + err := wininet.ClearSystemProxy() + if err != nil { + return err + } + p.isEnabled = false + return nil +} diff --git a/common/settings/system_proxy.go b/common/settings/system_proxy.go new file mode 100644 index 00000000..0635c6f6 --- /dev/null +++ b/common/settings/system_proxy.go @@ -0,0 +1,7 @@ +package settings + +type SystemProxy interface { + IsEnabled() bool + Enable() error + Disable() error +} diff --git a/common/settings/wifi.go b/common/settings/wifi.go new file mode 100644 index 00000000..62bef706 --- /dev/null +++ b/common/settings/wifi.go @@ -0,0 +1,9 @@ +package settings + +import "github.com/sagernet/sing-box/adapter" + +type WIFIMonitor interface { + ReadWIFIState() adapter.WIFIState + Start() error + Close() error +} diff --git a/common/settings/wifi_linux.go b/common/settings/wifi_linux.go new file mode 100644 index 00000000..9deed3c8 --- /dev/null +++ b/common/settings/wifi_linux.go @@ -0,0 +1,46 @@ +package settings + +import ( + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" +) + +type LinuxWIFIMonitor struct { + monitor WIFIMonitor +} + +func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + monitors := []func(func(adapter.WIFIState)) (WIFIMonitor, error){ + newNetworkManagerMonitor, + newIWDMonitor, + newWpaSupplicantMonitor, + newConnManMonitor, + } + var errors []error + for _, factory := range monitors { + monitor, err := factory(callback) + if err == nil { + return &LinuxWIFIMonitor{monitor: monitor}, nil + } + errors = append(errors, err) + } + return nil, E.Cause(E.Errors(errors...), "no supported WIFI manager found") +} + +func (m *LinuxWIFIMonitor) ReadWIFIState() adapter.WIFIState { + return m.monitor.ReadWIFIState() +} + +func (m *LinuxWIFIMonitor) Start() error { + if m.monitor != nil { + return m.monitor.Start() + } + return nil +} + +func (m *LinuxWIFIMonitor) Close() error { + if m.monitor != nil { + return m.monitor.Close() + } + return nil +} diff --git a/common/settings/wifi_linux_connman.go b/common/settings/wifi_linux_connman.go new file mode 100644 index 00000000..74706a7b --- /dev/null +++ b/common/settings/wifi_linux_connman.go @@ -0,0 +1,168 @@ +//go:build linux + +package settings + +import ( + "context" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + + "github.com/godbus/dbus/v5" +) + +type connmanMonitor struct { + conn *dbus.Conn + callback func(adapter.WIFIState) + cancel context.CancelFunc + signalChan chan *dbus.Signal +} + +func newConnManMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + cmObj := conn.Object("net.connman", "/") + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + call := cmObj.CallWithContext(ctx, "net.connman.Manager.GetServices", 0) + if call.Err != nil { + conn.Close() + return nil, call.Err + } + return &connmanMonitor{conn: conn, callback: callback}, nil +} + +func (m *connmanMonitor) ReadWIFIState() adapter.WIFIState { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + cmObj := m.conn.Object("net.connman", "/") + var services []interface{} + err := cmObj.CallWithContext(ctx, "net.connman.Manager.GetServices", 0).Store(&services) + if err != nil { + return adapter.WIFIState{} + } + + for _, service := range services { + servicePair, ok := service.([]interface{}) + if !ok || len(servicePair) != 2 { + continue + } + + serviceProps, ok := servicePair[1].(map[string]dbus.Variant) + if !ok { + continue + } + + typeVariant, hasType := serviceProps["Type"] + if !hasType { + continue + } + serviceType, ok := typeVariant.Value().(string) + if !ok || serviceType != "wifi" { + continue + } + + stateVariant, hasState := serviceProps["State"] + if !hasState { + continue + } + state, ok := stateVariant.Value().(string) + if !ok || (state != "online" && state != "ready") { + continue + } + + nameVariant, hasName := serviceProps["Name"] + if !hasName { + continue + } + ssid, ok := nameVariant.Value().(string) + if !ok || ssid == "" { + continue + } + + bssidVariant, hasBSSID := serviceProps["BSSID"] + if !hasBSSID { + return adapter.WIFIState{SSID: ssid} + } + bssid, ok := bssidVariant.Value().(string) + if !ok { + return adapter.WIFIState{SSID: ssid} + } + + return adapter.WIFIState{ + SSID: ssid, + BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")), + } + } + + return adapter.WIFIState{} +} + +func (m *connmanMonitor) Start() error { + if m.callback == nil { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + m.signalChan = make(chan *dbus.Signal, 10) + m.conn.Signal(m.signalChan) + + err := m.conn.AddMatchSignal( + dbus.WithMatchInterface("net.connman.Service"), + dbus.WithMatchSender("net.connman"), + ) + if err != nil { + return err + } + + state := m.ReadWIFIState() + go m.monitorSignals(ctx, m.signalChan, state) + m.callback(state) + + return nil +} + +func (m *connmanMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) { + for { + select { + case <-ctx.Done(): + return + case signal, ok := <-signalChan: + if !ok { + return + } + // godbus Signal.Name uses "interface.member" format (e.g. "net.connman.Service.PropertyChanged"), + // not just the member name. This differs from the D-Bus signal member in the match rule. + if signal.Name == "net.connman.Service.PropertyChanged" { + state := m.ReadWIFIState() + if state != lastState { + lastState = state + m.callback(state) + } + } + } + } +} + +func (m *connmanMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + if m.signalChan != nil { + m.conn.RemoveSignal(m.signalChan) + close(m.signalChan) + } + if m.conn != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchInterface("net.connman.Service"), + dbus.WithMatchSender("net.connman"), + ) + return m.conn.Close() + } + return nil +} diff --git a/common/settings/wifi_linux_iwd.go b/common/settings/wifi_linux_iwd.go new file mode 100644 index 00000000..327f9c47 --- /dev/null +++ b/common/settings/wifi_linux_iwd.go @@ -0,0 +1,190 @@ +//go:build linux + +package settings + +import ( + "context" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + + "github.com/godbus/dbus/v5" +) + +type iwdMonitor struct { + conn *dbus.Conn + callback func(adapter.WIFIState) + cancel context.CancelFunc + signalChan chan *dbus.Signal +} + +func newIWDMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + iwdObj := conn.Object("net.connman.iwd", "/") + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + call := iwdObj.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0) + if call.Err != nil { + conn.Close() + return nil, call.Err + } + return &iwdMonitor{conn: conn, callback: callback}, nil +} + +func (m *iwdMonitor) ReadWIFIState() adapter.WIFIState { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + iwdObj := m.conn.Object("net.connman.iwd", "/") + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := iwdObj.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects) + if err != nil { + return adapter.WIFIState{} + } + + for _, interfaces := range objects { + stationProps, hasStation := interfaces["net.connman.iwd.Station"] + if !hasStation { + continue + } + + stateVariant, hasState := stationProps["State"] + if !hasState { + continue + } + state, ok := stateVariant.Value().(string) + if !ok || state != "connected" { + continue + } + + connectedNetworkVariant, hasNetwork := stationProps["ConnectedNetwork"] + if !hasNetwork { + continue + } + networkPath, ok := connectedNetworkVariant.Value().(dbus.ObjectPath) + if !ok || networkPath == "/" { + continue + } + + networkInterfaces, hasNetworkPath := objects[networkPath] + if !hasNetworkPath { + continue + } + + networkProps, hasNetworkInterface := networkInterfaces["net.connman.iwd.Network"] + if !hasNetworkInterface { + continue + } + + nameVariant, hasName := networkProps["Name"] + if !hasName { + continue + } + ssid, ok := nameVariant.Value().(string) + if !ok { + continue + } + + connectedBSSVariant, hasBSS := stationProps["ConnectedAccessPoint"] + if !hasBSS { + return adapter.WIFIState{SSID: ssid} + } + bssPath, ok := connectedBSSVariant.Value().(dbus.ObjectPath) + if !ok || bssPath == "/" { + return adapter.WIFIState{SSID: ssid} + } + + bssInterfaces, hasBSSPath := objects[bssPath] + if !hasBSSPath { + return adapter.WIFIState{SSID: ssid} + } + + bssProps, hasBSSInterface := bssInterfaces["net.connman.iwd.BasicServiceSet"] + if !hasBSSInterface { + return adapter.WIFIState{SSID: ssid} + } + + addressVariant, hasAddress := bssProps["Address"] + if !hasAddress { + return adapter.WIFIState{SSID: ssid} + } + bssid, ok := addressVariant.Value().(string) + if !ok { + return adapter.WIFIState{SSID: ssid} + } + + return adapter.WIFIState{ + SSID: ssid, + BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")), + } + } + + return adapter.WIFIState{} +} + +func (m *iwdMonitor) Start() error { + if m.callback == nil { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + m.signalChan = make(chan *dbus.Signal, 10) + m.conn.Signal(m.signalChan) + + err := m.conn.AddMatchSignal( + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchSender("net.connman.iwd"), + ) + if err != nil { + return err + } + + state := m.ReadWIFIState() + go m.monitorSignals(ctx, m.signalChan, state) + m.callback(state) + + return nil +} + +func (m *iwdMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) { + for { + select { + case <-ctx.Done(): + return + case signal, ok := <-signalChan: + if !ok { + return + } + if signal.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" { + state := m.ReadWIFIState() + if state != lastState { + lastState = state + m.callback(state) + } + } + } + } +} + +func (m *iwdMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + if m.signalChan != nil { + m.conn.RemoveSignal(m.signalChan) + close(m.signalChan) + } + if m.conn != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchSender("net.connman.iwd"), + ) + return m.conn.Close() + } + return nil +} diff --git a/common/settings/wifi_linux_nm.go b/common/settings/wifi_linux_nm.go new file mode 100644 index 00000000..77d897d4 --- /dev/null +++ b/common/settings/wifi_linux_nm.go @@ -0,0 +1,165 @@ +//go:build linux + +package settings + +import ( + "context" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + + "github.com/godbus/dbus/v5" +) + +type networkManagerMonitor struct { + conn *dbus.Conn + callback func(adapter.WIFIState) + cancel context.CancelFunc + signalChan chan *dbus.Signal +} + +func newNetworkManagerMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + nmObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager") + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + var state uint32 + err = nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "State").Store(&state) + if err != nil { + conn.Close() + return nil, err + } + return &networkManagerMonitor{conn: conn, callback: callback}, nil +} + +func (m *networkManagerMonitor) ReadWIFIState() adapter.WIFIState { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + nmObj := m.conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager") + + var activeConnectionPaths []dbus.ObjectPath + err := nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "ActiveConnections").Store(&activeConnectionPaths) + if err != nil || len(activeConnectionPaths) == 0 { + return adapter.WIFIState{} + } + + for _, connectionPath := range activeConnectionPaths { + connObj := m.conn.Object("org.freedesktop.NetworkManager", connectionPath) + + var devicePaths []dbus.ObjectPath + err = connObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Connection.Active", "Devices").Store(&devicePaths) + if err != nil || len(devicePaths) == 0 { + continue + } + + for _, devicePath := range devicePaths { + deviceObj := m.conn.Object("org.freedesktop.NetworkManager", devicePath) + + var deviceType uint32 + err = deviceObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device", "DeviceType").Store(&deviceType) + if err != nil || deviceType != 2 { + continue + } + + var accessPointPath dbus.ObjectPath + err = deviceObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device.Wireless", "ActiveAccessPoint").Store(&accessPointPath) + if err != nil || accessPointPath == "/" { + continue + } + + apObj := m.conn.Object("org.freedesktop.NetworkManager", accessPointPath) + + var ssidBytes []byte + err = apObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.AccessPoint", "Ssid").Store(&ssidBytes) + if err != nil { + continue + } + + var hwAddress string + err = apObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.AccessPoint", "HwAddress").Store(&hwAddress) + if err != nil { + continue + } + + ssid := strings.TrimSpace(string(ssidBytes)) + if ssid == "" { + continue + } + + return adapter.WIFIState{ + SSID: ssid, + BSSID: strings.ToUpper(strings.ReplaceAll(hwAddress, ":", "")), + } + } + } + + return adapter.WIFIState{} +} + +func (m *networkManagerMonitor) Start() error { + if m.callback == nil { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + m.signalChan = make(chan *dbus.Signal, 10) + m.conn.Signal(m.signalChan) + + err := m.conn.AddMatchSignal( + dbus.WithMatchSender("org.freedesktop.NetworkManager"), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + ) + if err != nil { + return err + } + + state := m.ReadWIFIState() + go m.monitorSignals(ctx, m.signalChan, state) + m.callback(state) + + return nil +} + +func (m *networkManagerMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) { + for { + select { + case <-ctx.Done(): + return + case signal, ok := <-signalChan: + if !ok { + return + } + if signal.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" { + state := m.ReadWIFIState() + if state != lastState { + lastState = state + m.callback(state) + } + } + } + } +} + +func (m *networkManagerMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + if m.signalChan != nil { + m.conn.RemoveSignal(m.signalChan) + close(m.signalChan) + } + if m.conn != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchSender("org.freedesktop.NetworkManager"), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + ) + return m.conn.Close() + } + return nil +} diff --git a/common/settings/wifi_linux_wpa.go b/common/settings/wifi_linux_wpa.go new file mode 100644 index 00000000..51e76c1c --- /dev/null +++ b/common/settings/wifi_linux_wpa.go @@ -0,0 +1,225 @@ +package settings + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" +) + +var wpaSocketCounter atomic.Uint64 + +type wpaSupplicantMonitor struct { + socketPath string + callback func(adapter.WIFIState) + cancel context.CancelFunc + monitorConn *net.UnixConn + connMutex sync.Mutex +} + +func newWpaSupplicantMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + socketDirs := []string{"/var/run/wpa_supplicant", "/run/wpa_supplicant"} + for _, socketDir := range socketDirs { + entries, err := os.ReadDir(socketDir) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "." || entry.Name() == ".." { + continue + } + socketPath := filepath.Join(socketDir, entry.Name()) + id := wpaSocketCounter.Add(1) + localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d-%d", os.Getpid(), id), Net: "unixgram"} + remoteAddr := &net.UnixAddr{Name: socketPath, Net: "unixgram"} + conn, err := net.DialUnix("unixgram", localAddr, remoteAddr) + if err != nil { + continue + } + conn.Close() + return &wpaSupplicantMonitor{socketPath: socketPath, callback: callback}, nil + } + } + return nil, os.ErrNotExist +} + +func (m *wpaSupplicantMonitor) ReadWIFIState() adapter.WIFIState { + id := wpaSocketCounter.Add(1) + localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d-%d", os.Getpid(), id), Net: "unixgram"} + remoteAddr := &net.UnixAddr{Name: m.socketPath, Net: "unixgram"} + conn, err := net.DialUnix("unixgram", localAddr, remoteAddr) + if err != nil { + return adapter.WIFIState{} + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(3 * time.Second)) + + status, err := m.sendCommand(conn, "STATUS") + if err != nil { + return adapter.WIFIState{} + } + + var ssid, bssid string + var connected bool + scanner := bufio.NewScanner(strings.NewReader(status)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "wpa_state=") { + state := strings.TrimPrefix(line, "wpa_state=") + connected = state == "COMPLETED" + } else if strings.HasPrefix(line, "ssid=") { + ssid = strings.TrimPrefix(line, "ssid=") + } else if strings.HasPrefix(line, "bssid=") { + bssid = strings.TrimPrefix(line, "bssid=") + } + } + + if !connected || ssid == "" { + return adapter.WIFIState{} + } + + return adapter.WIFIState{ + SSID: ssid, + BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")), + } +} + +// sendCommand sends a command to wpa_supplicant and returns the response. +// Commands are sent without trailing newlines per the wpa_supplicant control +// interface protocol - the official wpa_ctrl.c sends raw command strings. +func (m *wpaSupplicantMonitor) sendCommand(conn *net.UnixConn, command string) (string, error) { + _, err := conn.Write([]byte(command)) + if err != nil { + return "", err + } + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + return "", err + } + + response := string(buf[:n]) + if strings.HasPrefix(response, "FAIL") { + return "", os.ErrInvalid + } + + return strings.TrimSpace(response), nil +} + +func (m *wpaSupplicantMonitor) Start() error { + if m.callback == nil { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + state := m.ReadWIFIState() + go m.monitorEvents(ctx, state) + m.callback(state) + + return nil +} + +func (m *wpaSupplicantMonitor) monitorEvents(ctx context.Context, lastState adapter.WIFIState) { + var consecutiveErrors int + var debounceTimer *time.Timer + var debounceMutex sync.Mutex + + localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-mon-%d", os.Getpid()), Net: "unixgram"} + remoteAddr := &net.UnixAddr{Name: m.socketPath, Net: "unixgram"} + conn, err := net.DialUnix("unixgram", localAddr, remoteAddr) + if err != nil { + return + } + defer conn.Close() + + m.connMutex.Lock() + m.monitorConn = conn + m.connMutex.Unlock() + + // ATTACH/DETACH commands use os_strcmp() for exact matching in wpa_supplicant, + // so they must be sent without trailing newlines. + // See: https://w1.fi/cgit/hostap/tree/wpa_supplicant/ctrl_iface_unix.c + _, err = conn.Write([]byte("ATTACH")) + if err != nil { + return + } + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil || !strings.HasPrefix(string(buf[:n]), "OK") { + return + } + + for { + select { + case <-ctx.Done(): + debounceMutex.Lock() + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceMutex.Unlock() + conn.Write([]byte("DETACH")) + return + default: + } + + conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + select { + case <-ctx.Done(): + return + default: + } + consecutiveErrors++ + if consecutiveErrors > 10 { + return + } + time.Sleep(time.Second) + continue + } + consecutiveErrors = 0 + + msg := string(buf[:n]) + if strings.Contains(msg, "CTRL-EVENT-CONNECTED") || strings.Contains(msg, "CTRL-EVENT-DISCONNECTED") { + debounceMutex.Lock() + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + state := m.ReadWIFIState() + if state != lastState { + lastState = state + m.callback(state) + } + }) + debounceMutex.Unlock() + } + } +} + +func (m *wpaSupplicantMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + m.connMutex.Lock() + if m.monitorConn != nil { + m.monitorConn.Close() + } + m.connMutex.Unlock() + return nil +} diff --git a/common/settings/wifi_stub.go b/common/settings/wifi_stub.go new file mode 100644 index 00000000..fd39af9e --- /dev/null +++ b/common/settings/wifi_stub.go @@ -0,0 +1,27 @@ +//go:build !linux && !windows + +package settings + +import ( + "os" + + "github.com/sagernet/sing-box/adapter" +) + +type stubWIFIMonitor struct{} + +func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + return nil, os.ErrInvalid +} + +func (m *stubWIFIMonitor) ReadWIFIState() adapter.WIFIState { + return adapter.WIFIState{} +} + +func (m *stubWIFIMonitor) Start() error { + return nil +} + +func (m *stubWIFIMonitor) Close() error { + return nil +} diff --git a/common/settings/wifi_windows.go b/common/settings/wifi_windows.go new file mode 100644 index 00000000..91b0d479 --- /dev/null +++ b/common/settings/wifi_windows.go @@ -0,0 +1,144 @@ +//go:build windows + +package settings + +import ( + "context" + "fmt" + "strings" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/winwlanapi" + + "golang.org/x/sys/windows" +) + +type windowsWIFIMonitor struct { + handle windows.Handle + callback func(adapter.WIFIState) + cancel context.CancelFunc + lastState adapter.WIFIState + mutex sync.Mutex +} + +func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) { + handle, err := winwlanapi.OpenHandle() + if err != nil { + return nil, err + } + + interfaces, err := winwlanapi.EnumInterfaces(handle) + if err != nil { + winwlanapi.CloseHandle(handle) + return nil, err + } + if len(interfaces) == 0 { + winwlanapi.CloseHandle(handle) + return nil, fmt.Errorf("no wireless interfaces found") + } + + return &windowsWIFIMonitor{ + handle: handle, + callback: callback, + }, nil +} + +func (m *windowsWIFIMonitor) ReadWIFIState() adapter.WIFIState { + interfaces, err := winwlanapi.EnumInterfaces(m.handle) + if err != nil || len(interfaces) == 0 { + return adapter.WIFIState{} + } + + for _, iface := range interfaces { + if iface.InterfaceState != winwlanapi.InterfaceStateConnected { + continue + } + + guid := iface.InterfaceGUID + attrs, err := winwlanapi.QueryCurrentConnection(m.handle, &guid) + if err != nil { + continue + } + + ssidLength := attrs.AssociationAttributes.SSID.Length + if ssidLength == 0 || ssidLength > winwlanapi.Dot11SSIDMaxLength { + continue + } + + ssid := string(attrs.AssociationAttributes.SSID.SSID[:ssidLength]) + bssid := formatBSSID(attrs.AssociationAttributes.BSSID) + + return adapter.WIFIState{ + SSID: strings.TrimSpace(ssid), + BSSID: bssid, + } + } + + return adapter.WIFIState{} +} + +func formatBSSID(mac winwlanapi.Dot11MacAddress) string { + return fmt.Sprintf("%02X%02X%02X%02X%02X%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]) +} + +func (m *windowsWIFIMonitor) Start() error { + if m.callback == nil { + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + m.lastState = m.ReadWIFIState() + + callbackFunc := func(data *winwlanapi.NotificationData, callbackContext uintptr) uintptr { + if data.NotificationSource != winwlanapi.NotificationSourceACM { + return 0 + } + switch data.NotificationCode { + case winwlanapi.NotificationACMConnectionComplete, + winwlanapi.NotificationACMDisconnected: + m.checkAndNotify() + } + return 0 + } + + callbackPointer := syscall.NewCallback(callbackFunc) + + err := winwlanapi.RegisterNotification(m.handle, winwlanapi.NotificationSourceACM, callbackPointer, 0) + if err != nil { + cancel() + return err + } + + go func() { + <-ctx.Done() + }() + + m.callback(m.lastState) + return nil +} + +func (m *windowsWIFIMonitor) checkAndNotify() { + m.mutex.Lock() + defer m.mutex.Unlock() + + state := m.ReadWIFIState() + if state != m.lastState { + m.lastState = state + if m.callback != nil { + m.callback(state) + } + } +} + +func (m *windowsWIFIMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + winwlanapi.UnregisterNotification(m.handle) + return winwlanapi.CloseHandle(m.handle) +} diff --git a/common/sniff/bittorrent.go b/common/sniff/bittorrent.go new file mode 100644 index 00000000..e4d9f4b8 --- /dev/null +++ b/common/sniff/bittorrent.go @@ -0,0 +1,107 @@ +package sniff + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + trackerConnectFlag = 0 + trackerProtocolID = 0x41727101980 + trackerConnectMinSize = 16 +) + +// BitTorrent detects if the stream is a BitTorrent connection. +// For the BitTorrent protocol specification, see https://www.bittorrent.org/beps/bep_0003.html +func BitTorrent(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error { + var first byte + err := binary.Read(reader, binary.BigEndian, &first) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + + if first != 19 { + return os.ErrInvalid + } + + const header = "BitTorrent protocol" + var protocol [19]byte + var n int + n, err = reader.Read(protocol[:]) + if string(protocol[:n]) != header[:n] { + return os.ErrInvalid + } + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if n < 19 { + return ErrNeedMoreData + } + + metadata.Protocol = C.ProtocolBitTorrent + return nil +} + +// UTP detects if the packet is a uTP connection packet. +// For the uTP protocol specification, see +// 1. https://www.bittorrent.org/beps/bep_0029.html +// 2. https://github.com/bittorrent/libutp/blob/2b364cbb0650bdab64a5de2abb4518f9f228ec44/utp_internal.cpp#L112 +func UTP(_ context.Context, metadata *adapter.InboundContext, packet []byte) error { + // A valid uTP packet must be at least 20 bytes long. + if len(packet) < 20 { + return os.ErrInvalid + } + + version := packet[0] & 0x0F + ty := packet[0] >> 4 + if version != 1 || ty > 4 { + return os.ErrInvalid + } + + // Validate the extensions + extension := packet[1] + reader := bytes.NewReader(packet[20:]) + for extension != 0 { + err := binary.Read(reader, binary.BigEndian, &extension) + if err != nil { + return err + } + if extension > 0x04 { + return os.ErrInvalid + } + var length byte + err = binary.Read(reader, binary.BigEndian, &length) + if err != nil { + return err + } + _, err = reader.Seek(int64(length), io.SeekCurrent) + if err != nil { + return err + } + } + metadata.Protocol = C.ProtocolBitTorrent + return nil +} + +// UDPTracker detects if the packet is a UDP Tracker Protocol packet. +// For the UDP Tracker Protocol specification, see https://www.bittorrent.org/beps/bep_0015.html +func UDPTracker(_ context.Context, metadata *adapter.InboundContext, packet []byte) error { + if len(packet) < trackerConnectMinSize { + return os.ErrInvalid + } + if binary.BigEndian.Uint64(packet[:8]) != trackerProtocolID { + return os.ErrInvalid + } + if binary.BigEndian.Uint32(packet[8:12]) != trackerConnectFlag { + return os.ErrInvalid + } + metadata.Protocol = C.ProtocolBitTorrent + return nil +} diff --git a/common/sniff/bittorrent_test.go b/common/sniff/bittorrent_test.go new file mode 100644 index 00000000..fcb5f6fa --- /dev/null +++ b/common/sniff/bittorrent_test.go @@ -0,0 +1,110 @@ +package sniff_test + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffBittorrent(t *testing.T) { + t.Parallel() + + packets := []string{ + "13426974546f7272656e742070726f746f636f6c0000000000100000e21ea9569b69bab33c97851d0298bdfa89bc90922d5554313631302dea812fcd6a3563e3be40c1d1", + "13426974546f7272656e742070726f746f636f6c00000000001000052aa4f5a7e209e54b32803d43670971c4c8caaa052d5452333030302d653369733079647675763638", + "13426974546f7272656e742070726f746f636f6c00000000001000052aa4f5a7e209e54b32803d43670971c4c8caaa052d5452343035302d6f7a316c6e79377931716130", + } + + for _, pkt := range packets { + pkt, err := hex.DecodeString(pkt) + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.BitTorrent(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.NoError(t, err) + require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol) + } +} + +func TestSniffIncompleteBittorrent(t *testing.T) { + t.Parallel() + + pkt, err := hex.DecodeString("13426974546f7272656e74") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.BitTorrent(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.ErrorIs(t, err, sniff.ErrNeedMoreData) +} + +func TestSniffNotBittorrent(t *testing.T) { + t.Parallel() + + pkt, err := hex.DecodeString("13426974546f7272656e75") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.BitTorrent(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.NotEmpty(t, err) + require.NotErrorIs(t, err, sniff.ErrNeedMoreData) +} + +func TestSniffUTP(t *testing.T) { + t.Parallel() + + packets := []string{ + "010041a282d7ee7b583afb160004000006d8318da776968f92d666f7963f32dae23ba0d2c810d8b8209cc4939f54fde9eeaa521c2c20c9ba7f43f4fb0375f28de06643b5e3ca4685ab7ac76adca99783be72ef05ed59ef4234f5712b75b4c7c0d7bee8fe2ca20ad626ba5bb0ffcc16bf06790896f888048cf72716419a07db1a3dca4550fbcea75b53e97235168a221cf3e553dfbb723961bd719fab038d86e0ecb74747f5a2cd669de1c4b9ad375f3a492d09d98cdfad745435625401315bbba98d35d32086299801377b93495a63a9efddb8d05f5b37a5c5b1c0a25e917f12007bb5e05013ada8aff544fab8cadf61d80ddb0b60f12741e44515a109d144fd53ef845acb4b5ccf0d6fc302d7003d76df3fc3423bb0237301c9e88f900c2d392a8e0fdb36d143cf7527a93fd0a2638b746e72f6699fffcd4fd15348fce780d4caa04382fd9faf1ca0ae377ca805da7536662b84f5ee18dd3ae38fcb095a7543e55f9069ae92c8cf54ae44e97b558d35e2545c66601ed2149cbc32bd6df199a2be7cf0da8b2ff137e0d23e776bc87248425013876d3a3cc31a83b424b752bd0346437f24b532978005d8f5b1b0be1a37a2489c32a18a9ad3118e3f9d30eb299bffae18e1f0677c2a5c185e62519093fe6bc2b7339299ea50a587989f726ca6443a75dd5bb936f6367c6355d80fae53ff529d740b2e5576e3eefdf1fdbfc69c3c8d8ac750512635de63e054bee1d3b689bc1b2bc3d2601e42a00b5c89066d173d4ae7ffedfd2274e5cf6d868fbe640aedb69b8246142f00b32d459974287537ddd5373460dcbc92f5cfdd7a3ed6020822ae922d947893752ca1983d0d32977374c384ac8f5ab566859019b7351526b9f13e932037a55bb052d9deb3b3c23317e0784fdc51a64f2159bfea3b069cf5caf02ee2c3c1a6b6b427bb16165713e8802d95b5c8ed77953690e994bd38c9ae113fedaf6ee7fc2b96c032ceafc2a530ad0422e84546b9c6ad8ef6ea02fa508abddd1805c38a7b42e9b7c971b1b636865ebec06ed754bb404cd6b4e6cc8cb77bd4a0c43410d5cd5ef8fe853a66d49b3b9e06cb141236cdbfdd5761601dc54d1250b86c660e0f898fe62526fdd9acf0eab60a3bbbb2151970461f28f10b31689594bea646c4b03ee197d63bdef4e5a7c22716b3bb9494a83b78ecd81b338b80ac6c09c43485b1b09ba41c74343832c78f0520c1d659ac9eb1502094141e82fb9e5e620970ebc0655514c43c294a7714cbf9a499d277daf089f556398a01589a77494bec8bfb60a108f3813b55368672b88c1af40f6b3c8b513f7c70c3e0efce85228b8b9ec67ba0393f9f7305024d8e2da6a26cf85613d14f249170ce1000089df4c9c260df7f8292aa2ecb5d5bac97656d59aa248caedea2d198e51ce87baece338716d114b458de02d65c9ff808ca5b5b73723b4d1e962d9ac2d98176544dc9984cf8554d07820ef3dd0861cfe57b478328046380de589adad94ee44743ffac73bb7361feca5d56f07cf8ce75080e261282ae30350d7882679b15cab9e7e53ddf93310b33f7390ae5d318bb53f387e6af5d0ef4f947fc9cb8e7e38b52c7f8d772ece6156b38d88796ea19df02c53723b44df7c76315a0de9462f27287e682d2b4cda1a68fe00d7e48c51ee981be44e1ca940fb5190c12655edb4a83c3a4f33e48a015692df4f0b3d61656e362aca657b5ae8c12db5a0db3db1e45135ee918b66918f40e53c4f83e9da0cddfe63f736ae751ab3837a30ae3220d8e8e311487093a7b90c7e7e40dd54ca750e19452f9193aa892aa6a6229ab493dadae988b1724f7898ee69c36d3eb7364c4adbeca811cfe2065873e78c2b6dfdf1595f7a7831c07e03cda82e4f86f76438dfb2b07c13638ce7b509cfa71b88b5102b39a203b423202088e1c2103319cb32c13c1e546ff8612fa194c95a7808ab767c265a1bd5fa0efed5c8ec1701876a00ec8", + "01001ecb68176f215d04326300100000dbcf30292d14b54e9ee2d115ee5b8ebc7fad3e882d4fcdd0c14c6b917c11cb4c6a9f410b52a33ae97c2ac77c7a2b122b8955e09af3c5c595f1b2e79ca57cfe44c44e069610773b9bc9ba223d7f6b383e3adddd03fb88a8476028e30979c2ef321ffc97c5c132bcf9ac5b410bbb5ec6cefca3c7209202a14c5ae922b6b157b0a80249d13ffe5b996af0bc8e54ba576d148372494303e7ead0602b05b9c8fc97d48508a028a04d63a1fd28b0edfcd5c51715f63188b53eefede98a76912dca98518551a8856567307a56a702cbfcc115ea0c755b418bc2c7b57721239b82f09fb24328a4b0ce0f109bcb2a64e04b8aadb1f8487585425acdf8fc4ec8ea93cfcec5ac098bb29d42ddef6e46b03f34a5de28316726699b7cb5195c33e5c48abe87d591d63f9991c84c30819d186d6e0e95fd83c8dff07aa669c4430989bcaccfeacb9bcadbdb4d8f1964dbeb9687745656edd30b21c66cc0a1d742a78717d134a19a7f02d285a4973b1a198c00cfdff4676608dc4f3e817e3463c3b4e2c80d3e8d4fbac541a58a2fb7ad6939f607f8144eff6c8b0adc28ee5609ea158987519892fb", + "21001ecb6817f2805d044fd700100000dbd03029", + "410277ef0b1fb1f60000000000040000c233000000080000000000000000", + } + + for _, pkt := range packets { + pkt, err := hex.DecodeString(pkt) + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.UTP(context.TODO(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol) + } +} + +func TestSniffUDPTracker(t *testing.T) { + t.Parallel() + + connectPackets := []string{ + "00000417271019800000000078e90560", + "00000417271019800000000022c5d64d", + "000004172710198000000000b3863541", + } + + for _, pkt := range connectPackets { + pkt, err := hex.DecodeString(pkt) + require.NoError(t, err) + + var metadata adapter.InboundContext + err = sniff.UDPTracker(context.TODO(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol) + } +} + +func TestSniffNotUTP(t *testing.T) { + t.Parallel() + + packets := []string{ + "0102736470696e674958d580121500000000000079aaed6717a39c27b07c0c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + } + for _, pkt := range packets { + pkt, err := hex.DecodeString(pkt) + require.NoError(t, err) + + var metadata adapter.InboundContext + err = sniff.UTP(context.TODO(), &metadata, pkt) + require.Error(t, err) + } +} diff --git a/common/sniff/dns.go b/common/sniff/dns.go new file mode 100644 index 00000000..7125a08e --- /dev/null +++ b/common/sniff/dns.go @@ -0,0 +1,57 @@ +package sniff + +import ( + "context" + "encoding/binary" + "io" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +func StreamDomainNameQuery(readCtx context.Context, metadata *adapter.InboundContext, reader io.Reader) error { + var length uint16 + err := binary.Read(reader, binary.BigEndian, &length) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if length < 12 { + return os.ErrInvalid + } + buffer := buf.NewSize(int(length)) + defer buffer.Release() + var n int + n, err = buffer.ReadFullFrom(reader, buffer.FreeLen()) + packet := buffer.Bytes() + if n > 2 && packet[2]&0x80 != 0 { // QR + return os.ErrInvalid + } + if n > 5 && packet[4] == 0 && packet[5] == 0 { // QDCOUNT + return os.ErrInvalid + } + for i := 6; i < 10; i++ { + // ANCOUNT, NSCOUNT + if n > i && packet[i] != 0 { + return os.ErrInvalid + } + } + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + return DomainNameQuery(readCtx, metadata, packet) +} + +func DomainNameQuery(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error { + var msg mDNS.Msg + err := msg.Unpack(packet) + if err != nil || msg.Response || len(msg.Question) == 0 || len(msg.Answer) > 0 || len(msg.Ns) > 0 { + return err + } + metadata.Protocol = C.ProtocolDNS + return nil +} diff --git a/common/sniff/dns_test.go b/common/sniff/dns_test.go new file mode 100644 index 00000000..d78b0bf5 --- /dev/null +++ b/common/sniff/dns_test.go @@ -0,0 +1,53 @@ +package sniff_test + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffDNS(t *testing.T) { + t.Parallel() + query, err := hex.DecodeString("740701000001000000000000012a06676f6f676c6503636f6d0000010001") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.DomainNameQuery(context.TODO(), &metadata, query) + require.NoError(t, err) + require.Equal(t, C.ProtocolDNS, metadata.Protocol) +} + +func TestSniffStreamDNS(t *testing.T) { + t.Parallel() + query, err := hex.DecodeString("001e740701000001000000000000012a06676f6f676c6503636f6d0000010001") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.StreamDomainNameQuery(context.TODO(), &metadata, bytes.NewReader(query)) + require.NoError(t, err) + require.Equal(t, C.ProtocolDNS, metadata.Protocol) +} + +func TestSniffIncompleteStreamDNS(t *testing.T) { + t.Parallel() + query, err := hex.DecodeString("001e740701000001000000000000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.StreamDomainNameQuery(context.TODO(), &metadata, bytes.NewReader(query)) + require.ErrorIs(t, err, sniff.ErrNeedMoreData) +} + +func TestSniffNotStreamDNS(t *testing.T) { + t.Parallel() + query, err := hex.DecodeString("001e740701000000000000000000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.StreamDomainNameQuery(context.TODO(), &metadata, bytes.NewReader(query)) + require.NotEmpty(t, err) + require.NotErrorIs(t, err, sniff.ErrNeedMoreData) +} diff --git a/common/sniff/dtls.go b/common/sniff/dtls.go new file mode 100644 index 00000000..e2704a27 --- /dev/null +++ b/common/sniff/dtls.go @@ -0,0 +1,32 @@ +package sniff + +import ( + "context" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" +) + +func DTLSRecord(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error { + const fixedHeaderSize = 13 + if len(packet) < fixedHeaderSize { + return os.ErrInvalid + } + contentType := packet[0] + switch contentType { + case 20, 21, 22, 23, 25: + default: + return os.ErrInvalid + } + versionMajor := packet[1] + if versionMajor != 0xfe { + return os.ErrInvalid + } + versionMinor := packet[2] + if versionMinor != 0xff && versionMinor != 0xfd { + return os.ErrInvalid + } + metadata.Protocol = C.ProtocolDTLS + return nil +} diff --git a/common/sniff/dtls_test.go b/common/sniff/dtls_test.go new file mode 100644 index 00000000..48c9b42b --- /dev/null +++ b/common/sniff/dtls_test.go @@ -0,0 +1,33 @@ +package sniff_test + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffDTLSClientHello(t *testing.T) { + t.Parallel() + packet, err := hex.DecodeString("16fefd0000000000000000007e010000720000000000000072fefd668a43523798e064bd806d0c87660de9c611a59bbdfc3892c4e072d94f2cafc40000000cc02bc02fc00ac014c02cc0300100003c000d0010000e0403050306030401050106010807ff01000100000a00080006001d00170018000b00020100000e000900060008000700010000170000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.DTLSRecord(context.Background(), &metadata, packet) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolDTLS) +} + +func TestSniffDTLSClientApplicationData(t *testing.T) { + t.Parallel() + packet, err := hex.DecodeString("17fefd000100000000000100440001000000000001a4f682b77ecadd10f3f3a2f78d90566212366ff8209fd77314f5a49352f9bb9bd12f4daba0b4736ae29e46b9714d3b424b3e6d0234736619b5aa0d3f") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.DTLSRecord(context.Background(), &metadata, packet) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolDTLS) +} diff --git a/common/sniff/http.go b/common/sniff/http.go new file mode 100644 index 00000000..012f2c99 --- /dev/null +++ b/common/sniff/http.go @@ -0,0 +1,28 @@ +package sniff + +import ( + std_bufio "bufio" + "context" + "errors" + "io" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/protocol/http" +) + +func HTTPHost(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error { + request, err := http.ReadRequest(std_bufio.NewReader(reader)) + if err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + return E.Cause1(ErrNeedMoreData, err) + } else { + return err + } + } + metadata.Protocol = C.ProtocolHTTP + metadata.Domain = M.ParseSocksaddr(request.Host).AddrString() + return nil +} diff --git a/common/sniff/http_test.go b/common/sniff/http_test.go new file mode 100644 index 00000000..9f64efa8 --- /dev/null +++ b/common/sniff/http_test.go @@ -0,0 +1,30 @@ +package sniff_test + +import ( + "context" + "strings" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + + "github.com/stretchr/testify/require" +) + +func TestSniffHTTP1(t *testing.T) { + t.Parallel() + pkt := "GET / HTTP/1.1\r\nHost: www.google.com\r\nAccept: */*\r\n\r\n" + var metadata adapter.InboundContext + err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt)) + require.NoError(t, err) + require.Equal(t, metadata.Domain, "www.google.com") +} + +func TestSniffHTTP1WithPort(t *testing.T) { + t.Parallel() + pkt := "GET / HTTP/1.1\r\nHost: www.gov.cn:8080\r\nAccept: */*\r\n\r\n" + var metadata adapter.InboundContext + err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt)) + require.NoError(t, err) + require.Equal(t, metadata.Domain, "www.gov.cn") +} diff --git a/common/sniff/internal/qtls/qtls.go b/common/sniff/internal/qtls/qtls.go new file mode 100644 index 00000000..9742de1e --- /dev/null +++ b/common/sniff/internal/qtls/qtls.go @@ -0,0 +1,148 @@ +package qtls + +import ( + "crypto" + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "io" + + "golang.org/x/crypto/hkdf" +) + +const ( + VersionDraft29 = 0xff00001d + Version1 = 0x1 + Version2 = 0x6b3343cf +) + +var ( + SaltOld = []byte{0xaf, 0xbf, 0xec, 0x28, 0x99, 0x93, 0xd2, 0x4c, 0x9e, 0x97, 0x86, 0xf1, 0x9c, 0x61, 0x11, 0xe0, 0x43, 0x90, 0xa8, 0x99} + SaltV1 = []byte{0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a} + SaltV2 = []byte{0x0d, 0xed, 0xe3, 0xde, 0xf7, 0x00, 0xa6, 0xdb, 0x81, 0x93, 0x81, 0xbe, 0x6e, 0x26, 0x9d, 0xcb, 0xf9, 0xbd, 0x2e, 0xd9} +) + +const ( + HKDFLabelKeyV1 = "quic key" + HKDFLabelKeyV2 = "quicv2 key" + HKDFLabelIVV1 = "quic iv" + HKDFLabelIVV2 = "quicv2 iv" + HKDFLabelHeaderProtectionV1 = "quic hp" + HKDFLabelHeaderProtectionV2 = "quicv2 hp" +) + +func AEADAESGCMTLS13(key, nonceMask []byte) cipher.AEAD { + if len(nonceMask) != 12 { + panic("tls: internal error: wrong nonce length") + } + aes, err := aes.NewCipher(key) + if err != nil { + panic(err) + } + aead, err := cipher.NewGCM(aes) + if err != nil { + panic(err) + } + + ret := &xorNonceAEAD{aead: aead} + copy(ret.nonceMask[:], nonceMask) + return ret +} + +type xorNonceAEAD struct { + nonceMask [12]byte + aead cipher.AEAD +} + +func (f *xorNonceAEAD) NonceSize() int { return 8 } // 64-bit sequence number +func (f *xorNonceAEAD) Overhead() int { return f.aead.Overhead() } +func (f *xorNonceAEAD) explicitNonceLen() int { return 0 } + +func (f *xorNonceAEAD) Seal(out, nonce, plaintext, additionalData []byte) []byte { + for i, b := range nonce { + f.nonceMask[4+i] ^= b + } + result := f.aead.Seal(out, f.nonceMask[:], plaintext, additionalData) + for i, b := range nonce { + f.nonceMask[4+i] ^= b + } + + return result +} + +func (f *xorNonceAEAD) Open(out, nonce, ciphertext, additionalData []byte) ([]byte, error) { + for i, b := range nonce { + f.nonceMask[4+i] ^= b + } + result, err := f.aead.Open(out, f.nonceMask[:], ciphertext, additionalData) + for i, b := range nonce { + f.nonceMask[4+i] ^= b + } + + return result, err +} + +func HKDFExpandLabel(hash crypto.Hash, secret, context []byte, label string, length int) []byte { + b := make([]byte, 3, 3+6+len(label)+1+len(context)) + binary.BigEndian.PutUint16(b, uint16(length)) + b[2] = uint8(6 + len(label)) + b = append(b, []byte("tls13 ")...) + b = append(b, []byte(label)...) + b = b[:3+6+len(label)+1] + b[3+6+len(label)] = uint8(len(context)) + b = append(b, context...) + out := make([]byte, length) + n, err := hkdf.Expand(hash.New, secret, b).Read(out) + if err != nil || n != length { + panic("quic: HKDF-Expand-Label invocation failed unexpectedly") + } + return out +} + +func ReadUvarint(r io.ByteReader) (uint64, error) { + firstByte, err := r.ReadByte() + if err != nil { + return 0, err + } + // the first two bits of the first byte encode the length + len := 1 << ((firstByte & 0xc0) >> 6) + b1 := firstByte & (0xff - 0xc0) + if len == 1 { + return uint64(b1), nil + } + b2, err := r.ReadByte() + if err != nil { + return 0, err + } + if len == 2 { + return uint64(b2) + uint64(b1)<<8, nil + } + b3, err := r.ReadByte() + if err != nil { + return 0, err + } + b4, err := r.ReadByte() + if err != nil { + return 0, err + } + if len == 4 { + return uint64(b4) + uint64(b3)<<8 + uint64(b2)<<16 + uint64(b1)<<24, nil + } + b5, err := r.ReadByte() + if err != nil { + return 0, err + } + b6, err := r.ReadByte() + if err != nil { + return 0, err + } + b7, err := r.ReadByte() + if err != nil { + return 0, err + } + b8, err := r.ReadByte() + if err != nil { + return 0, err + } + return uint64(b8) + uint64(b7)<<8 + uint64(b6)<<16 + uint64(b5)<<24 + uint64(b4)<<32 + uint64(b3)<<40 + uint64(b2)<<48 + uint64(b1)<<56, nil +} diff --git a/common/sniff/ntp.go b/common/sniff/ntp.go new file mode 100644 index 00000000..8b844c5b --- /dev/null +++ b/common/sniff/ntp.go @@ -0,0 +1,58 @@ +package sniff + +import ( + "context" + "encoding/binary" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" +) + +func NTP(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error { + // NTP packets must be at least 48 bytes long (standard NTP header size). + pLen := len(packet) + if pLen < 48 { + return os.ErrInvalid + } + // Check the LI (Leap Indicator) and Version Number (VN) in the first byte. + // We'll primarily focus on ensuring the version is valid for NTP. + // Many NTP versions are used, but let's check for generally accepted ones (3 & 4 for IPv4, plus potential extensions/customizations) + firstByte := packet[0] + li := (firstByte >> 6) & 0x03 // Extract LI + vn := (firstByte >> 3) & 0x07 // Extract VN + mode := firstByte & 0x07 // Extract Mode + + // Leap Indicator should be a valid value (0-3). + if li > 3 { + return os.ErrInvalid + } + + // Version Check (common NTP versions are 3 and 4) + if vn != 3 && vn != 4 { + return os.ErrInvalid + } + + // Check the Mode field for a client request (Mode 3). This validates it *is* a request. + if mode != 3 { + return os.ErrInvalid + } + + // Check Root Delay and Root Dispersion. While not strictly *required* for a request, + // we can check if they appear to be reasonable values (not excessively large). + rootDelay := binary.BigEndian.Uint32(packet[4:8]) + rootDispersion := binary.BigEndian.Uint32(packet[8:12]) + + // Check for unreasonably large root delay and dispersion. NTP RFC specifies max values of approximately 16 seconds. + // Convert to milliseconds for easy comparison. Each unit is 1/2^16 seconds. + if float64(rootDelay)/65536.0 > 16.0 { + return os.ErrInvalid + } + if float64(rootDispersion)/65536.0 > 16.0 { + return os.ErrInvalid + } + + metadata.Protocol = C.ProtocolNTP + + return nil +} diff --git a/common/sniff/ntp_test.go b/common/sniff/ntp_test.go new file mode 100644 index 00000000..5da94785 --- /dev/null +++ b/common/sniff/ntp_test.go @@ -0,0 +1,33 @@ +package sniff_test + +import ( + "context" + "encoding/hex" + "os" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffNTP(t *testing.T) { + t.Parallel() + packet, err := hex.DecodeString("1b0006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.NTP(context.Background(), &metadata, packet) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolNTP) +} + +func TestSniffNTPFailed(t *testing.T) { + t.Parallel() + packet, err := hex.DecodeString("400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.NTP(context.Background(), &metadata, packet) + require.ErrorIs(t, err, os.ErrInvalid) +} diff --git a/common/sniff/quic.go b/common/sniff/quic.go new file mode 100644 index 00000000..049bd2c1 --- /dev/null +++ b/common/sniff/quic.go @@ -0,0 +1,373 @@ +package sniff + +import ( + "bytes" + "context" + "crypto" + "crypto/aes" + "crypto/tls" + "encoding/binary" + "io" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/ja3" + "github.com/sagernet/sing-box/common/sniff/internal/qtls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/crypto/hkdf" +) + +func QUICClientHello(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error { + reader := bytes.NewReader(packet) + typeByte, err := reader.ReadByte() + if err != nil { + return err + } + if typeByte&0x40 == 0 { + return E.New("bad type byte") + } + var versionNumber uint32 + err = binary.Read(reader, binary.BigEndian, &versionNumber) + if err != nil { + return err + } + if versionNumber != qtls.VersionDraft29 && versionNumber != qtls.Version1 && versionNumber != qtls.Version2 { + return E.New("bad version") + } + packetType := (typeByte & 0x30) >> 4 + if packetType == 0 && versionNumber == qtls.Version2 || packetType == 2 && versionNumber != qtls.Version2 || packetType > 2 { + return E.New("bad packet type") + } + + destConnIDLen, err := reader.ReadByte() + if err != nil { + return err + } + + if destConnIDLen == 0 || destConnIDLen > 20 { + return E.New("bad destination connection id length") + } + + destConnID := make([]byte, destConnIDLen) + _, err = io.ReadFull(reader, destConnID) + if err != nil { + return err + } + + srcConnIDLen, err := reader.ReadByte() + if err != nil { + return err + } + + _, err = io.CopyN(io.Discard, reader, int64(srcConnIDLen)) + if err != nil { + return err + } + + tokenLen, err := qtls.ReadUvarint(reader) + if err != nil { + return err + } + + _, err = io.CopyN(io.Discard, reader, int64(tokenLen)) + if err != nil { + return err + } + + packetLen, err := qtls.ReadUvarint(reader) + if err != nil { + return err + } + + hdrLen := int(reader.Size()) - reader.Len() + if hdrLen+int(packetLen) > len(packet) { + return os.ErrInvalid + } + + _, err = io.CopyN(io.Discard, reader, 4) + if err != nil { + return err + } + + pnBytes := make([]byte, aes.BlockSize) + _, err = io.ReadFull(reader, pnBytes) + if err != nil { + return err + } + + var salt []byte + switch versionNumber { + case qtls.Version1: + salt = qtls.SaltV1 + case qtls.Version2: + salt = qtls.SaltV2 + default: + salt = qtls.SaltOld + } + var hkdfHeaderProtectionLabel string + switch versionNumber { + case qtls.Version2: + hkdfHeaderProtectionLabel = qtls.HKDFLabelHeaderProtectionV2 + default: + hkdfHeaderProtectionLabel = qtls.HKDFLabelHeaderProtectionV1 + } + initialSecret := hkdf.Extract(crypto.SHA256.New, destConnID, salt) + secret := qtls.HKDFExpandLabel(crypto.SHA256, initialSecret, []byte{}, "client in", crypto.SHA256.Size()) + hpKey := qtls.HKDFExpandLabel(crypto.SHA256, secret, []byte{}, hkdfHeaderProtectionLabel, 16) + block, err := aes.NewCipher(hpKey) + if err != nil { + return err + } + mask := make([]byte, aes.BlockSize) + block.Encrypt(mask, pnBytes) + newPacket := make([]byte, len(packet)) + copy(newPacket, packet) + newPacket[0] ^= mask[0] & 0xf + for i := range newPacket[hdrLen : hdrLen+4] { + newPacket[hdrLen+i] ^= mask[i+1] + } + packetNumberLength := newPacket[0]&0x3 + 1 + if hdrLen+int(packetNumberLength) > int(packetLen)+hdrLen { + return os.ErrInvalid + } + var packetNumber uint32 + switch packetNumberLength { + case 1: + packetNumber = uint32(newPacket[hdrLen]) + case 2: + packetNumber = uint32(binary.BigEndian.Uint16(newPacket[hdrLen:])) + case 3: + packetNumber = uint32(newPacket[hdrLen+2]) | uint32(newPacket[hdrLen+1])<<8 | uint32(newPacket[hdrLen])<<16 + case 4: + packetNumber = binary.BigEndian.Uint32(newPacket[hdrLen:]) + default: + return E.New("bad packet number length") + } + extHdrLen := hdrLen + int(packetNumberLength) + copy(newPacket[extHdrLen:hdrLen+4], packet[extHdrLen:]) + data := newPacket[extHdrLen : int(packetLen)+hdrLen] + + var keyLabel string + var ivLabel string + switch versionNumber { + case qtls.Version2: + keyLabel = qtls.HKDFLabelKeyV2 + ivLabel = qtls.HKDFLabelIVV2 + default: + keyLabel = qtls.HKDFLabelKeyV1 + ivLabel = qtls.HKDFLabelIVV1 + } + + key := qtls.HKDFExpandLabel(crypto.SHA256, secret, []byte{}, keyLabel, 16) + iv := qtls.HKDFExpandLabel(crypto.SHA256, secret, []byte{}, ivLabel, 12) + cipher := qtls.AEADAESGCMTLS13(key, iv) + nonce := make([]byte, int32(cipher.NonceSize())) + binary.BigEndian.PutUint64(nonce[len(nonce)-8:], uint64(packetNumber)) + decrypted, err := cipher.Open(newPacket[extHdrLen:extHdrLen], nonce, data, newPacket[:extHdrLen]) + if err != nil { + return err + } + var frameType byte + var fragments []qCryptoFragment + decryptedReader := bytes.NewReader(decrypted) + const ( + frameTypePadding = 0x00 + frameTypePing = 0x01 + frameTypeAck = 0x02 + frameTypeAck2 = 0x03 + frameTypeCrypto = 0x06 + frameTypeConnectionClose = 0x1c + ) + var frameTypeList []uint8 + for { + frameType, err = decryptedReader.ReadByte() + if err == io.EOF { + break + } + frameTypeList = append(frameTypeList, frameType) + switch frameType { + case frameTypePadding: + continue + case frameTypePing: + continue + case frameTypeAck, frameTypeAck2: + _, err = qtls.ReadUvarint(decryptedReader) // Largest Acknowledged + if err != nil { + return err + } + _, err = qtls.ReadUvarint(decryptedReader) // ACK Delay + if err != nil { + return err + } + ackRangeCount, err := qtls.ReadUvarint(decryptedReader) // ACK Range Count + if err != nil { + return err + } + _, err = qtls.ReadUvarint(decryptedReader) // First ACK Range + if err != nil { + return err + } + for i := 0; i < int(ackRangeCount); i++ { + _, err = qtls.ReadUvarint(decryptedReader) // Gap + if err != nil { + return err + } + _, err = qtls.ReadUvarint(decryptedReader) // ACK Range Length + if err != nil { + return err + } + } + if frameType == 0x03 { + _, err = qtls.ReadUvarint(decryptedReader) // ECT0 Count + if err != nil { + return err + } + _, err = qtls.ReadUvarint(decryptedReader) // ECT1 Count + if err != nil { + return err + } + _, err = qtls.ReadUvarint(decryptedReader) // ECN-CE Count + if err != nil { + return err + } + } + case frameTypeCrypto: + var offset uint64 + offset, err = qtls.ReadUvarint(decryptedReader) + if err != nil { + return err + } + var length uint64 + length, err = qtls.ReadUvarint(decryptedReader) + if err != nil { + return err + } + index := len(decrypted) - decryptedReader.Len() + fragments = append(fragments, qCryptoFragment{offset, length, decrypted[index : index+int(length)]}) + _, err = decryptedReader.Seek(int64(length), io.SeekCurrent) + if err != nil { + return err + } + case frameTypeConnectionClose: + _, err = qtls.ReadUvarint(decryptedReader) // Error Code + if err != nil { + return err + } + _, err = qtls.ReadUvarint(decryptedReader) // Frame Type + if err != nil { + return err + } + var length uint64 + length, err = qtls.ReadUvarint(decryptedReader) // Reason Phrase Length + if err != nil { + return err + } + _, err = decryptedReader.Seek(int64(length), io.SeekCurrent) // Reason Phrase + if err != nil { + return err + } + default: + return os.ErrInvalid + } + } + if metadata.SniffContext != nil { + fragments = append(fragments, metadata.SniffContext.([]qCryptoFragment)...) + metadata.SniffContext = nil + } + var frameLen uint64 + for _, fragment := range fragments { + frameLen += fragment.length + } + buffer := buf.NewSize(5 + int(frameLen)) + defer buffer.Release() + buffer.WriteByte(0x16) + binary.Write(buffer, binary.BigEndian, uint16(0x0303)) + binary.Write(buffer, binary.BigEndian, uint16(frameLen)) + var index uint64 + var length int +find: + for { + for _, fragment := range fragments { + if fragment.offset == index { + buffer.Write(fragment.payload) + index = fragment.offset + fragment.length + length++ + continue find + } + } + break + } + metadata.Protocol = C.ProtocolQUIC + fingerprint, err := ja3.Compute(buffer.Bytes()) + if err != nil { + metadata.SniffContext = fragments + return E.Cause1(ErrNeedMoreData, err) + } + metadata.Domain = fingerprint.ServerName + for metadata.Client == "" { + if len(frameTypeList) == 1 { + metadata.Client = C.ClientFirefox + break + } + if frameTypeList[0] == frameTypeCrypto && isZero(frameTypeList[1:]) { + if len(fingerprint.Versions) == 2 && fingerprint.Versions[0]&ja3.GreaseBitmask == 0x0A0A && + len(fingerprint.EllipticCurves) == 5 && fingerprint.EllipticCurves[0]&ja3.GreaseBitmask == 0x0A0A { + metadata.Client = C.ClientSafari + break + } + if len(fingerprint.CipherSuites) == 1 && fingerprint.CipherSuites[0] == tls.TLS_AES_256_GCM_SHA384 && + len(fingerprint.EllipticCurves) == 1 && fingerprint.EllipticCurves[0] == uint16(tls.X25519) && + len(fingerprint.SignatureAlgorithms) == 1 && fingerprint.SignatureAlgorithms[0] == uint16(tls.ECDSAWithP256AndSHA256) { + metadata.Client = C.ClientSafari + break + } + } + + if frameTypeList[len(frameTypeList)-1] == frameTypeCrypto && isZero(frameTypeList[:len(frameTypeList)-1]) { + metadata.Client = C.ClientQUICGo + break + } + + if count(frameTypeList, frameTypeCrypto) > 1 || count(frameTypeList, frameTypePing) > 0 { + if isQUICGo(fingerprint) { + metadata.Client = C.ClientQUICGo + } else { + metadata.Client = C.ClientChromium + } + break + } + + metadata.Client = C.ClientUnknown + //nolint:staticcheck + break + } + return nil +} + +func isZero(slices []uint8) bool { + for _, slice := range slices { + if slice != 0 { + return false + } + } + return true +} + +func count(slices []uint8, value uint8) int { + var times int + for _, slice := range slices { + if slice == value { + times++ + } + } + return times +} + +type qCryptoFragment struct { + offset uint64 + length uint64 + payload []byte +} diff --git a/common/sniff/quic_blacklist.go b/common/sniff/quic_blacklist.go new file mode 100644 index 00000000..56a15152 --- /dev/null +++ b/common/sniff/quic_blacklist.go @@ -0,0 +1,29 @@ +package sniff + +import ( + "github.com/sagernet/sing-box/common/ja3" +) + +const ( + // X25519Kyber768Draft00 - post-quantum curve used by Go crypto/tls + x25519Kyber768Draft00 uint16 = 0x11EC // 4588 + // renegotiation_info extension used by Go crypto/tls + extensionRenegotiationInfo uint16 = 0xFF01 // 65281 +) + +// isQUICGo detects native quic-go by checking for Go crypto/tls specific features. +// Note: uQUIC with Chromium mimicry cannot be reliably distinguished from real Chromium +// since it uses the same TLS fingerprint, so it will be identified as Chromium. +func isQUICGo(fingerprint *ja3.ClientHello) bool { + for _, curve := range fingerprint.EllipticCurves { + if curve == x25519Kyber768Draft00 { + return true + } + } + for _, ext := range fingerprint.Extensions { + if ext == extensionRenegotiationInfo { + return true + } + } + return false +} diff --git a/common/sniff/quic_capture_test.go b/common/sniff/quic_capture_test.go new file mode 100644 index 00000000..4c9eb838 --- /dev/null +++ b/common/sniff/quic_capture_test.go @@ -0,0 +1,188 @@ +package sniff_test + +import ( + "context" + "crypto/tls" + "encoding/hex" + "errors" + "net" + "testing" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + + "github.com/stretchr/testify/require" +) + +func TestSniffQUICQuicGoFingerprint(t *testing.T) { + t.Parallel() + const testSNI = "test.example.com" + + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer udpConn.Close() + + serverAddr := udpConn.LocalAddr().(*net.UDPAddr) + packetsChan := make(chan [][]byte, 1) + + go func() { + var packets [][]byte + udpConn.SetReadDeadline(time.Now().Add(3 * time.Second)) + for i := 0; i < 10; i++ { + buf := make([]byte, 2048) + n, _, err := udpConn.ReadFromUDP(buf) + if err != nil { + break + } + packets = append(packets, buf[:n]) + } + packetsChan <- packets + }() + + clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer clientConn.Close() + + tlsConfig := &tls.Config{ + ServerName: testSNI, + InsecureSkipVerify: true, + NextProtos: []string{"h3"}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, _ = quic.Dial(ctx, clientConn, serverAddr, tlsConfig, &quic.Config{}) + + select { + case packets := <-packetsChan: + t.Logf("Captured %d packets", len(packets)) + + var metadata adapter.InboundContext + for i, pkt := range packets { + err := sniff.QUICClientHello(context.Background(), &metadata, pkt) + t.Logf("Packet %d: err=%v, domain=%s, client=%s", i, err, metadata.Domain, metadata.Client) + if metadata.Domain != "" { + break + } + } + + t.Logf("\n=== quic-go TLS Fingerprint Analysis ===") + t.Logf("Domain: %s", metadata.Domain) + t.Logf("Client: %s", metadata.Client) + t.Logf("Protocol: %s", metadata.Protocol) + + // The client should be identified as quic-go, not chromium + // Current issue: it's being identified as chromium + if metadata.Client == "chromium" { + t.Log("WARNING: quic-go is being misidentified as chromium!") + } + + case <-time.After(5 * time.Second): + t.Fatal("Timeout") + } +} + +func TestSniffQUICInitialFromQuicGo(t *testing.T) { + t.Parallel() + + const testSNI = "test.example.com" + + // Create UDP listener to capture ALL initial packets + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer udpConn.Close() + + serverAddr := udpConn.LocalAddr().(*net.UDPAddr) + + // Channel to receive captured packets + packetsChan := make(chan [][]byte, 1) + + // Start goroutine to capture packets + go func() { + var packets [][]byte + udpConn.SetReadDeadline(time.Now().Add(3 * time.Second)) + for i := 0; i < 5; i++ { // Capture up to 5 packets + buf := make([]byte, 2048) + n, _, err := udpConn.ReadFromUDP(buf) + if err != nil { + break + } + packets = append(packets, buf[:n]) + } + packetsChan <- packets + }() + + // Create QUIC client connection (will fail but we capture the initial packet) + clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer clientConn.Close() + + tlsConfig := &tls.Config{ + ServerName: testSNI, + InsecureSkipVerify: true, + NextProtos: []string{"h3"}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // This will fail (no server) but sends initial packet + _, _ = quic.Dial(ctx, clientConn, serverAddr, tlsConfig, &quic.Config{}) + + // Wait for captured packets + select { + case packets := <-packetsChan: + t.Logf("Captured %d QUIC packets", len(packets)) + + for i, packet := range packets { + t.Logf("Packet %d: length=%d, first 30 bytes: %x", i, len(packet), packet[:min(30, len(packet))]) + } + + // Test sniffer with first packet + if len(packets) > 0 { + var metadata adapter.InboundContext + err := sniff.QUICClientHello(context.Background(), &metadata, packets[0]) + + t.Logf("First packet sniff error: %v", err) + t.Logf("Protocol: %s", metadata.Protocol) + t.Logf("Domain: %s", metadata.Domain) + t.Logf("Client: %s", metadata.Client) + + // If first packet needs more data, try with subsequent packets + // IMPORTANT: reuse metadata to accumulate CRYPTO fragments via SniffContext + if errors.Is(err, sniff.ErrNeedMoreData) && len(packets) > 1 { + t.Log("First packet needs more data, trying subsequent packets with shared context...") + for i := 1; i < len(packets); i++ { + // Reuse same metadata to accumulate fragments + err = sniff.QUICClientHello(context.Background(), &metadata, packets[i]) + t.Logf("Packet %d sniff result: err=%v, domain=%s, sniffCtx=%v", i, err, metadata.Domain, metadata.SniffContext != nil) + if metadata.Domain != "" || (err != nil && !errors.Is(err, sniff.ErrNeedMoreData)) { + break + } + } + } + + // Print hex dump for debugging + t.Logf("First packet hex:\n%s", hex.Dump(packets[0][:min(256, len(packets[0]))])) + + // Log final results + t.Logf("Final: Protocol=%s, Domain=%s, Client=%s", metadata.Protocol, metadata.Domain, metadata.Client) + + // Verify SNI extraction + if metadata.Domain == "" { + t.Errorf("Failed to extract SNI, expected: %s", testSNI) + } else { + require.Equal(t, testSNI, metadata.Domain, "SNI should match") + } + + // Check client identification - quic-go should be identified as quic-go, not chromium + t.Logf("Client identified as: %s (expected: quic-go)", metadata.Client) + } + + case <-time.After(5 * time.Second): + t.Fatal("Timeout waiting for QUIC packets") + } +} diff --git a/common/sniff/quic_test.go b/common/sniff/quic_test.go new file mode 100644 index 00000000..e2f53724 --- /dev/null +++ b/common/sniff/quic_test.go @@ -0,0 +1,93 @@ +package sniff_test + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffQUICChromeNew(t *testing.T) { + t.Parallel() + pkt, err := hex.DecodeString("ca0000000108e241a0c601413b4f004046006d8f15dae9999edf39d58df6762822b9a2ab996d7f6a10044338af3b51b1814bc4ac0fa5a87c34c6ae604af8cabc5957c5240174deefc8e378719ffdab2ae4e15bf4514bea4489ad89c322f75f9a383c90d126a0b21104cb519c2bb32e6a134e86896452e942b26c519b8c7ac9e4c99fae5e1f65cf08fb98443b30e4567932e8fb0789820d8f33037b59ac8113530258c9467dfb52489396dae01f099d28b234efa107fa411f2a1ffa2abe74988e03d662d4296024e95ce0fe1671724937157f77b84990478a2d4060676cf0827b4e8c600654111750414dafa0cccb332f3020c2922a015f445df5edc9c7d2d1ceea9fddcc9ff821c9183aa39a70da20fcc057579e1051c1c899148d6cf9d08b4919822082d040d1ce03ca4f216be6cb7ef03db6df0993ef1ccce5c8c648980554f41704526e1809d2545739f5872e75ec797db1c99f5682e2eda9363cb32aa367b7b363c782ddbacf874183cc15c8a2db068dd4093eebdd096ad33832a7939deb0a872279744f5a56dc001ba62fac973bf680f3b362bdd336add4dd102f462b773bf70bfce1921070a802a92025273a177186d1a643081b42175eb789ccddadb71033ef4feacbf6fd282ab622cf61669d73cda559e411c6ccdd8f003443b6933b7729b7a357aa4aa2fba0f365f829a4d497afb5dc2648a53bc9f3e786d955069d0a4781088a5463747dfe9958ea19ea444eae947ec6a67640955f710f93640084f3fbb8ad259b68dbc0ee0b7fab2d81bffd83ed8a6d33522dbfef43bec0a0fb4bdf1cb712dc4ced0680c0687fa240fd157baa232b1c84e14adce6421cf9270f9b3972f98fc67b344b8a4f1fb551e26f7f76d484ed9f8197f231dc5d9a44cc0ddce73d7f810a620851f4e97eb5037ab5135d7c3be5b80cc32d19910b8387aca64c93c02dc3e35238b78e6aff470722078982e58802844932b6041446bfdcc97ba640cbb86721bcd0f40f27b77aa6287ce5674ec1720134b9302875482c3269787e004b9edb483d44f326eef38c0e83cb46af96488c2e696bc2524567fb29c1e8edcd5a73615496d172d46a9d29e0505c0018b7bbb00165eca0389e09c4b1d73b6cc4a2f735a720650134a2e98e8105e20695cf231b92586237dfe0f99c897414e51c21627496276535f07abb53fb2b554376fe520fa45a3e944fd91dfe7a72aead08842b6b63d8edf861fb911954c83bd9a896eb9da4af5eff646455069d747facd4e77c254096843bff7c3e9031dbdf8dc37ea45f1122922fcbc322ec1378f3c7c1af0da62e1052e6210f1b23073f93a82d90e14cb20bc4501d487a1c848674d57a7c269b13590b3a99d8b8b4f6d0dfbd1d2cbbe7a32c0d5c84ae7ec438b0b19f3862d8fabaa828d06c7e3c6967405cd56a1ae90f38633e2ee0e3ecfca3df399fe12f029e0860a1a30da010300d0c94f0bf56091d00011488c1429928b21c739ebf50ba8be91116315d3173f6d2c56735722478c4d74392ba84d1727036b3d64e8c2263b0f33cb8086be587ca6b3940259c06afa2683868856529303ae12e91d7ca874568be7f2bfaa0656dfab0ed31ed90eaea10fb7f3433ec59a334abe6211d547fa0c825ac45d3691e749d15432008de83e9f6d98f368359137ae803d9189b3386f800c7c0cf4b615d1983cf82d9981a8105b60a80fe66c9b0d439b5ba153dd19e9e7483a01cf3b02b4597540b38e658d4eb8455e030b2bf2690bdd78c23f16fe5") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.Equal(t, metadata.Protocol, C.ProtocolQUIC) + require.Empty(t, metadata.Client) + require.ErrorIs(t, err, sniff.ErrNeedMoreData) + pkt, err = hex.DecodeString("cc0000000108e241a0c601413b4f004046006d8f15dae9999edf39d58df6762822b9a2ab996d7f6a10044338af3b51b1814bc4ac0fa5a87c34c6ae604af8cabc5957c5240174deefc8e378719ffdab2ae4e15bf4514bea44894b626c685cd5d5c965f7e97b3a1bdc520b75813e747f37a3ae83ad38b9ca2acb0de4fc9424839a50c8fb815a62b498609fbbc59145698860e0509cc08a04d1b119daef844ba2f09c16e2665e5cc0b47624b71f7b950c54fd56b4a1fbb826cba44eeeee3949ced8f5de60d4c81b19ee59f75aa1abb33f22c6b13c27095eb1e99cff01fdc93e6e88da2622ee18c08a79f508befd7e33e99bca60e64bef9a47b764384bd93823daeeb6fcb4d7cfbc4ab53eff59b3636f6dcaaf229b5a94941b5712807166b9bd5e82cb4a9708a71451c4cd6f6e33fb2fe40c8c70dd51a30b37ff9c5e35783debde0093fde19ce074b4887b3c90980b107b9c0f32cf61a66f37c251b789abc4d27fc421207966846c8cc7faa42d9af6ad355a6bc94cb78223b612be8b3e2a4df61fee83a674a0ceb8b7c3a29b97102cda22fecdf6a4628e5b612bc17eab64d6f75feedd0b106c0419e484e66725759964cb5935ac5125e5ae920cd280bd40df57c1d7ae1845700bd4eb7b7ab12bc0850950bfe6e69edd6ac1daa5db2c2b07484327196e561c513462d72872dc6771c39f6b60d46a1f2c92343b7338450a0ef8e39f97fa70652b3a12cd04043698951627aaaa82cc95e76df92021d30e8014c984f12eea0143de8b17e5e4a36ec07bf4814251b391f168a59ef75afcd2319249aaba930f06bb7a11b9491e6f71b3d5774a6503a965e94edd0a67737282fc9cb0271779ff14151b7aa9267bb8f7d643185512515aeea513c0c98bfae782381a3317064195d8825cf8b25c17cdab5fced02612a3f2870e40df57e6ca3f08228a2b04e8de1425eb4b970118f9bbdc212223ff86a5d6b648cdf2366722f21de4b14a1014879eadb69215cdb1aa2a9f4f310ecfe3116214fe3ab0a23f4775a0a54b48d7dfd8f7283ed687b3ac7e1a7e42a0bdc3478aba8651c03e1e9cc9df17d106b8130afe854269b0103b7a696f452721887b19d8181830073c9f10684c65f96d3a6c6efbae044eec03d6399e001fa44d54635dc72f9b8ea6b87d0f452cad1e1e32273e2b47c40f2730235adcae8523b8282f86b8cf1ab63ae54aaa06130df3bbf6ecac7d7d1d43d2a87aea837267ff8ccfaa4b7e47b7ded909e6603d0b928a304f8915c839153598adc4178eb48bc0e98ad7793d7980275e1e491ba4847a4a04ae30fe7f5cc7d4b6f4f63a525e9964d72245860ca76a668a4654adb6619f16e9db79131e5675b93cafb96c92f1da8464d4fef2a22e7f9db695965fe2cc27ea30974629c8fe17cfa2f860179e1eb9faaa88a91ec9ce6da28c1a2894c3b932b5e1c807146718cc77ca13c61eaae00c7c99e019f599772064b198c5c2c5e863336367673630b417ac845ddb7c93b0856317e5d64bab208c5730abc2c63536784fbeaaec139dffc917e775715f1e42164ddef5138d4d163609ab3fbdcab968f8738385c0e7e34ff3cf7771a1dc5ba25a8850fdf96dabafa21f9065f307457ce9af4b7a73450c9d20a3b46fa8d3a1163d22bd01a7d17f0ec274181bf9640fa941427694bfeb1346089f7a851efe0fbb7a2041fa6bb6541ccbad77dd3e1a97999fc05f1fef070e7b5c4b385b8b2a8cc32483fdeba6a373970de2fa4139ba18e5916f949aab0aab2894") + require.NoError(t, err) + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.ErrorIs(t, err, sniff.ErrNeedMoreData) + pkt, err = hex.DecodeString("c20000000108e241a0c601413b4f004046006d8f15dae9999edf39d58df6762822b9a2ab996d7f6a10044338af3b51b1814bc4ac0fa5a87c34c6ae604af8cabc5957c5240174deefc8e378719ffdab2ae4e15bf4514bea4489e2ff30c43a5f63beb2e4501ce7754085bcbe838003a0b4bccb53863c0766df7eac073c2bdc170772b157997945acdc2ab2e84750cc9aa0ffa0fdc023da7fc565a14f87f7c563dbc9183dd226aab79957d263f66e64b85a1b15a24516bd2c7c04eea4fa0a34ef9849c21585db2e4adb7c05e265c4f38d8ffe4cbed0f3b0e68f3693bf1f726c3fb135b8e32a5d22931d7c55fc2ff4b9a354933ab14544df3cdaf3e3217dfb8d7feb3465dc34df6320ea486f12e5b2d609aaa5f4515c20c86fc440f8087be0ee3d339835746ae2573c2afdee6bb6ef7e9eb541feae9209391b2902cfb0bdaccd9da8d290714638b7da588d4a656ca6eabba78b7363922d6037cf060b161a42019d4feb4156459103cffdeefd0e63114af2b0e0c39e70ebc7fecb8dd1ebb8d60b2137f509bb7dcef5f1d3e06ab1d391466652d57440a410fb4f58a6ce1fb62feb453241f64e110709f59a3d9ebdac94f811337d0e4a80fd6b56b2a70cd6eebbf98e1661291da6bf5beb8b8afc376dfd20eb76afe709e8e8f28e0ef82105954e346546ad25973df43f4acddbec0ffd9b215f62abebebf71305b5ea993560316f69430bf5afe50420340622f802b5830f3bcebffff04980c75a59d28902879e5d51a4fb21062a4ae13c42297075b21d54ee04303879c1157e7470c1451673c98a2f3921f2f3e8f6acfe85b01caaca66b59e5ebffbfe68e5e9ab17e9a1b857eb409df91cb76767fc1814fd3c522a9b117edd0b02526e469cb4afb291a4dcc74c79b47ec6e7ce558c597129366f83ec306b11d2598c705fd4ee9ee99df6b7039bef13b08fc6f26853ad213829d24f895747d45a47414f931c583fb6c3e4f6c27d0c2b81a5f3cee390ec6314e1fec637e8d28b675e97caafdfbf8c25d34a635083a7553d219dd80dbb39087d74c6ad6192ca6f48a3ff8d47db41b2a492c63fcd780012780931dae0a325f9dcbd772d09a700f132c4bc1d9809b25b9751b694eb72a8ba4db7208d2b1bab63e1845208e4f841ea30218a559db98751589716b6d059ca673378f5fe7c7d8a1c82e14a561c47313bbcc278412ba86ffb2b87ec308eab9df696f5b4b54f8e361731bf232820a02a35fda7e5d4bf01b8f005ad299a055116e7b23c181f15a66442cf6032ca477bccc55b79d424eb4f245847bd81a581dc369dd20b1a4892733bde3c38e492c0039f69f2b947a4dc251a49ee7ccc0f36b3b75a555fa1d126db75f94dab60f52f6b15a877a0c380b59f82d35c570bc5f8051e9ef87db51f52383d47b50829b7f9e947ccc67aa280566aa48b4a85c1c7eca6f542789d8abcc050f1aa3cc221b6859656a21454aa21c7bfb9d12115f61c3ed46263ade68a8d3679fa62a659a5da7817406bd16618fccf33ed208ada1b03584e8b485d3cb6ed80a0774e60b6cd55aff64169ea998cf8235997049515abac58e0169ca07fb1c8c4c8b2803ba9d27b44c045d0a1cac86e5e188195c68001f53eb44851b6d821fc01ccbb41e27f38e6ddd66540c2d62ed6e0d551e22c0f26b60078c74a6302a1ed3d9e8fc0861257a63f6ac4e759fd54bff088becd28e30944a6c15db4fc8ae6244346869add946d9d92c430d737e042fa18b28a8ed64d1e8987ad9061cdc1335f") + require.NoError(t, err) + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, "www.google.com", metadata.Domain) +} + +func TestSniffQUICChromium(t *testing.T) { + t.Parallel() + pkt, err := hex.DecodeString("c30000000108f40d654cc09b27f5000044d08a94548e57e43cc5483f129986187c432d58d46674830442988f869566a6e31e2ae37c9f7acbf61cc81621594fab0b3dfdc1635460b32389563dc8e74006315661cd22694114612973c1c45910621713a48b375854f095e8a77ccf3afa64e972f0f7f7002f50e0b014b1b146ea47c07fb20b73ad5587872b51a0b3fafdf1c4cf4fe6f8b112142392efa25d993abe2f42582be145148bdfe12edcd96c3655b65a4781b093e5594ba8e3ae5320f12e8314fc3ca374128cc43381046c322b964681ed4395c813b28534505118201459665a44b8f0abead877de322e9040631d20b05f15b81fa7ff785d4041aecc37c7e2ccdc5d1532787ce566517e8985fd5c200dbfd1e67bc255efaba94cfc07bb52fea4a90887413b134f2715b5643542aa897c6116486f428d82da64d2a2c1e1bdd40bd592558901a554b003d6966ac5a7b8b9413eddbf6ef21f28386c74981e3ce1d724c341e95494907626659692720c81114ca4acea35a14c402cfa3dc2228446e78dc1b81fa4325cf7e314a9cad6a6bdff33b3351dcba74eb15fae67f1227283aa4cdd64bcadf8f19358333f8549b596f4350297b5c65274565869d497398339947b9d3d064e5b06d39d34b436d8a41c1a3880de10bd26c3b1c5b4e2a49b0d4d07b8d90cd9e92bc611564d19ea8ec33099e92033caf21f5307dbeaa4708b99eb313bff99e2081ac25fd12d6a72e8335e0724f6718fe023cd0ad0d6e6a6309f09c9c391eec2bc08e9c3210a043c08e1759f354c121f6517fff4d6e20711a871e41285d48d930352fddffb92c96ba57df045ce99f8bfdfa8edc0969ce68a51e9fbb4f54b956d9df74a9e4af27ed2b27839bce1cffeca8333c0aaee81a570217442f9029ba8fedb84a2cf4be4d910982d891ea00e816c7fb98e8020e896a9c6fdd9106611da0a99dde18df1b7a8f6327acb1eed9ad93314451e48cb0dfb9571728521ca3db2ac0968159d5622556a55d51a422d11995b650949aaefc5d24c16080446dfc4fbc10353f9f93ce161ab513367bb89ab83988e0630b689e174e27bcfcc31996ee7b0bca909e251b82d69a28fee5a5d662e127508cd19dbbe5097b7d5b62a49203d66764197a527e472e2627e44a93d44177dace9d60e7d0e03305ddf4cfe47cdf2362e14de79ef46a6763ce696cd7854a48d9419a0817507a4713ffd4977b906d4f2b5fb6dbe1bd15bc505d5fea582190bf531a45d5ee026da8918547fd5105f15e5d061c7b0cf80a34990366ed8e91e13c2f0d85e5dad537298808d193cf54b7eaac33f10051f74cb6b75e52f81618c36f03d86aef613ba237a1a793ba1539938a38f62ccaf7bd5f6c5e0ce53cde4012fcf2b758214a0422d2faaa798e86e19d7481b42df2b36a73d287ff28c20cce01ce598771fec16a8f1f00305c06010126013a6c1de9f589b4e79d693717cd88ad1c42a2d99fa96617ba0bc6365b68e21a70ebc447904aa27979e1514433cfd83bfec09f137c747d47582cb63eb28f873fb94cf7a59ff764ddfbb687d79a58bb10f85949269f7f72c611a5e0fbb52adfa298ff060ec2eb7216fd7302ea8fb07798cbb3be25cb53ac8161aac2b5bbcfbcfb01c113d28bd1cb0333fb89ac82a95930f7abded0a2f5a623cc6a1f62bf3f38ef1b81c1e50a634f657dbb6770e4af45879e2fb1e00c742e7b52205c8015b5c0f5b1e40186ff9aa7288ab3e01a51fb87761f9bc6837082af109b39cc9f620") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.Equal(t, metadata.Protocol, C.ProtocolQUIC) + require.Empty(t, metadata.Client) + require.ErrorIs(t, err, sniff.ErrNeedMoreData) + pkt, err = hex.DecodeString("c90000000108f40d654cc09b27f5000044d073eb38807026d4088455e650e7ccf750d01a72f15f9bfc8ff40d223499db1a485cff14dbd45b9be118172834dc35dca3cf62f61a1266f40b92faf3d28d67a466cfdca678ddced15cd606d31959cf441828467857b226d1a241847c82c57312cefe68ba5042d929919bcd4403b39e5699fe87dda05df1b3801e048edee792458e9b1a9b1d4039df05847bcee3be567494b5876e3bd4c3220fe9dfdb2c07d77410f907f744251ef15536cc03b267d3668d5b75bc1ad2fe735cd3bb73519dd9f1625a49e17ad27bdeccf706c83b5ea339a0a05dd0072f4a8f162bd29926b4997f05613c6e4b0270b0c02805ca0543f27c1ff8505a5750bdd33529ee73c491050a10c6903f53c1121dbe0380e84c007c8df74a1b02443ed80ba7766aef5549e618d4fd249844ee28565142005369869299e8c3035ecef3d799f6cada8549e75b4ce4cbf4c85ef071fd7ff067b1ca9b5968dc41d13d011f6d7843823bac97acb1eb8ee45883f0f254b5f9bd4c763b67e2d8c70a7618a0ef0de304cf597a485126e09f8b2fd795b394c0b4bc4cd2634c2057970da2c798c5e8af7aed4f76f5e25d04e3f8c9c5a5b150d17e0d4c74229898c69b8dc7b8bcc9d359eb441de75c68fbdebec62fb669dcccfb1aad03e3fa073adb2ccf7bb14cbaf99e307d2c903ee71a8f028102eb510caee7e7397512086a78d1f95635c7d06845b5a708652dc4e5cd61245aae5b3c05b84815d84d367bce9b9e3f6d6b90701ac3679233c14d5ce2a1eff26469c966266dc6284bdb95c9c6158934c413a872ce22101e4163e3293d236b301592ca4ccacc1fd4c37066e79c2d9857c8a2560dcf0b33b19163c4240c471b19907476e7e25c65f7eb37276594a0f6b4c33c340cc3284178f17ac5e34dbe7509db890e4ddfd0540fbf9deb32a0101d24fe58b26c5f81c627db9d6ae59d7a111a3d5d1f6109f4eec0d0234e6d73c73a44f50999462724b51ce0fd8283535d70d9e83872c79c59897407a0736741011ae5c64862eb0712f9e7b07aa1d5418ca3fde8626257c6fe418f3c5479055bb2b0ab4c25f649923fc2a41c79aaa7d0f3af6d8b8cf06f61f0230d09bbb60bb49b9e49cc5973748a6cf7ffdee7804d424f9423c63e7ff22f4bd24e4867636ef9fe8dd37f59941a8a47c27765caa8e875a30b62834f17c569227e5e6ed15d58e05d36e76332befad065a2cd4079e66d5af189b0337624c89b1560c3b1b0befd5c1f20e6de8e3d664b3ac06b3d154b488983e14aa93266f5f8b621d2a9bb7ccce509eb26e025c9c45f7cccc09ce85b3103af0c93ce9822f82ecb168ca3177829afb2ea0da2c380e7b1728add55a5d42632e2290363d4cbe432b67e13691648e1acfab22cf0d551eee857709b428bb78e27a45aff6eca301c02e4d13cf36cc2494fdd1aef8dede6e18febd79dca4c6964d09b91c25a08f0947c76ab5104de9404459c2edf5f4adb9dfd771be83656f77fbbafb1ad3281717066010be8778952495383c9f2cf0a38527228c662a35171c5981731f1af09bab842fe6c3162ad4152a4221f560eb6f9bea66b294ffbd3643da2fe34096da13c246505452540177a2a0a1a69106e5cfc279a4890fc3be2952f26be245f930e6c2d9e7e26ee960481e72b99594a1185b46b94b6436d00ba6c70ffe135d43907c92c6f1c09fb9453f103730714f5700fa4347f9715c774cb04a7218dacc66d9c2fade18b14e684aa7fc9ebda0a28") + require.NoError(t, err) + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, metadata.Domain, "google.com") +} + +func TestSniffUQUICChrome115(t *testing.T) { + t.Parallel() + pkt, err := hex.DecodeString("cb0000000108181e17c387120abc000044d0705b6a3ef9ee37a8d3949a7d393ed078243c2ee2c3627fad1c3f107c117f4f071131ad61848068fcbbe5c65803c147f7f8ec5e2cd77b77beea23ba779d936dccac540f8396400e3190ea35cc2942af4171a04cb14272491920f90124959f44e80143678c0b52f5d31af319aaa589db2f940f004562724d0af40f737e1bb0002a071e6a1dbc9f52c64f070806a5010abed0298053634d9c9126bd7949ae5087998ade762c0ad06691d99c0875a38c601fc1ee77bfc3b8c11381829f2c9bdd022f4499c43ff1d6aee1a0d296861461dda217d22c568b276016ef3929e59d2f7d7ddf7809920fb7dc805641608949f3f8466ab3d37149aac501f0b107d808f3add4acfc657e4a82e2b88e97a6c74a00c419548760ab3414ba13915c78a1ca79dceee8d59fbe299f20b671ac44823218368b2a026baa55170cf549519ac21dbb6d31d248bd339438a4e663bcdca1fe3ae3f045a5dc19b122e9db9d7af9757076666dda4e9ace1c67def77fa14786f0cab3ebf7a270ea6e2b37838318c95779f80c3b8471948d0046c3614b3a13477c939a39a7855d85d13522a45ae0765739cd5eedef87237e824a929983ace27640c6495dbf5a72fa0b96893dc5d28f3988249a57bdb458d460b4a57043de3da750a76b6e5d2259247ca27cd864ea18f0d09aa62ab6eb7c014fb43179b2a1963d170b756cce83eeaebff78a828d025c811848e16ff862a8080d093478cd2208c8ab0803178325bc0d9d6bb25e62fa50c4ad15cf80916da6578796932036c72e43eb480d1e423ed812ac75a97722f8416529b82ba8ee2219c535012282bb17066bd53e78b87a71abdb7ebdb2a7c2766ff8397962e87d0f85485b64b4ee81cc84f99c47f33f2b0872716441992773f59186e38d32dbf5609a6fda94cb928cd25f5a7a3ab736b5a4236b6d5409ab18892c6a4d3480fc2350abfdf0bab1cedb55bdf0760fdb703e6688f4de596254eed4ed3e67eb03d0717b8e15b31e735214e588c87ae36bc6c310e1894b4c15143e4ccf287b2dbc707a946bf9671ae3c574f9486b2c82eec784bba4cbc76113cbe0f97ac8c13cfa38f2925ab9d06887a612ce48280a91d7e074e6caf898d88e2bbf71360899abf48a03f9a70cf2891199f2d63b116f4871af0ebb4f4906792f66cc21d1609f189138532875c129a68c73e7bcd3b5d8100beac1d8ac4b20d94a59ac8df5a5af58a9acb20413eadf97189f5f19ff889155f0c4d37514ec184eb6903967ff38a41fc087abb0f2cad3761d6e3f95f92a09a72f5c065b16e188088b87460241f27ecdb1bc6ece92c8d36b2d68b58d0fb4d4b3c928c579ade8ae5a995833aadd297c30a37f7bc35440fc97070e1b198e0fac00157452177d16d2803b4239997452b4ad3a951173bdec47a033fd7f8a7942accaa9aaa905b3c5a2175e7c3e07c48bf25331727fd69cd1e64d74d8c9d4a6f8f4491adb7bc911505cb19877083d8f21a12475e313fccf57877ff3556318e81ed9145dd9427f2b65275440893035f417481f721c69215af8ae103530cd0a1d35bf2cb5a27628f8d44d7c6f5ec12ce79d0a8333e0eb48771115d0a191304e46b8db19bbe5c40f1c346dde98e76ff5e21ff38d2c34e60cb07766ed529dd6d2cbacd7fbf1ed8a0e6e40decad0ca5021e91552be87c156d3ae2fffef41c65b14ba6d488f2c3227a1ab11ffce0e2dc47723a69da27a67a7f26e1cb13a7103af9b87a8db8e18ea") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolQUIC) + require.Equal(t, metadata.Client, C.ClientChromium) + require.Equal(t, metadata.Domain, "www.google.com") +} + +func TestSniffQUICFirefox(t *testing.T) { + t.Parallel() + pkt, err := hex.DecodeString("c8000000010867f174d7ebfe1b0803cd9c20004286de068f7963cf1736349ee6ebe0ddcd3e4cd0041a51ced3f7ce9eea1fb595458e74bdb4b792b16449bd8cae71419862c4fcbe766eaec7d1af65cd298e1dd46f8bd94a77ab4ca28c54b8e9773de3f02d7cb2463c9f7dcacfb311f024b0266ec6ab7bfb615b4148333fb4d4ece7c4cd90029ca30c2cbae2216b428499ec873fa125797e71c5a5da85087760ad37ca610020f71b76e82651c47576e20bf33cf676cb2d400b8c09d3c8cb4e21c47d2b21f6b68732bef30c8cefd5c723fc23eb29e6f7f65a5e52aad9055c1fb3d8b1811f0380b38d7e2eee8eb37dd5bd5d4ca4b66540175d916289d88a9df7c161964d713999c5057d27edb298ef5164352568b0d4bac3c15d90456e8fd460e41b81d0ec1b1e94b87d3333cc6908b018e0914ae1f214d73e75398da3d55a0106161d3a75897b4eb66e98c59010fae75f0d367d38be48c3a5c58bc8a30773c3fff50690ac9d487822f85d4f5713d626baa92d36e858dd21259cf814bce0b90d18da88a1ade40113e5a088cdb304a2558879152a8cf15c1839e056378aa41acba6fcb9974dee54bd50b5d4eb2c475654e06c0ec06b7f18f4462c808684843a1071041b9bfb2688324e0120144944416e30e83eedbbbcbc275b1f53762d3db18f0998ce54f0e1c512946b4098f07781d49264fa148f4c8220a3b02e73d7f15554aa370aafeff73cb75c52c494edf90f0261abfdd32a4d670f729de50266162687aa8efe14b8506f313b058b02aaaab5825428f5f4510b8e49451fdcb7b5a4af4b59c831afcb89fb4f64dba78e3b38387e87e9e8cdaa1f3b700a87c7d442388863b8950296e5773b38f308d62f52548c0bbf308e40540747cca5bf99b1345bc0d70b8f0e69a83b85a8d69f795b87f93e2bfccf52b529afea4ff6fd456957000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolQUIC) + require.Equal(t, metadata.Client, C.ClientFirefox) + require.Equal(t, metadata.Domain, "www.google.com") +} + +func TestSniffQUICSafari(t *testing.T) { + t.Parallel() + pkt, err := hex.DecodeString("c70000000108e4e75af2e223198a0000449ef2d83cb4473a62765eba67424cd4a5817315cbf55a9e8daaca360904b0bae60b1629cfeba11e2dfbbf5ea4c588cb134e31af36fd7a409fb0fcc0187e9b56037ac37964ed20a8c1ca19fd6cfd53398324b3d0c71537294f769db208fa998b6811234a4a7eb3b5eceb457ae92e3a2d98f7c110702db8064b5c29fa3298eb1d0529fd445a84a5fd6ff8709be90f8af4f94998d8a8f2953bb05ad08c80668eca784c6aec959114e68e5b827e7c41c79f2277c716a967e7fcc8d1b77442e6cb18329dbedb34b473516b468cba5fc20659e655fbe37f36408289b9a475fcee091bd82828d3be00367e9e5cec9423bb97854abdada1d7562a3777756eb3bddef826ddc1ef46137cb01bb504a54d410d9bcb74cd5f959050c84edf343fa6a49708c228a758ee7adbbadf260b2f1984911489712e2cb364a3d6520badba4b7e539b9c163eeddfd96c0abb0de151e47496bb9750be76ee17ccdb61d35d2c6795174037d6f9d282c3f36c4d9a90b64f3b6ddd0cf4d9ed8e6f7805e25928fa04b087e63ae02761df30720cc01dfc32b64c575c8a66ef82e9a17400ff80cd8609b93ba16d668f4aa734e71c4a5d145f14ee1151bec970214e0ff83fc3e1e85d8694f2975f9155c57c18b7b69bb6a36832a9435f1f4b346a7be188f3a75f9ad2cc6ad0a3d26d6fa7d4c1179bd49bd5989d15ba43ff602890107db96484695086627356750d7b2b3b714ba65d564654e8f60ac10f5b6d3bfb507e8eaa31bab1da2d676195046d165c7f8b32829c9f9b68d97b2af7ac04a1369357e4b65de2b2f24eaf27cc8d95e05db001adebe726f927a94e43e62ce671e6e306e16f05aafcbe6c49080e80286d7939f375023d110a5ad9069364ae928ca480454a9dcddd61bc48b7efeb716a5bd6c7cd39c486ceb20c738af6abf22ba1ddd8b4a3b781fc2f251173409e1aadccbd7514e97106d0ebfc3af6e59445f74cd733a1ba99b10fce3fb4e9f7c88f5e25b567f5ba2b8dabacd375e7faf7634bfa178cbe51aee63032c5126b196ea47b02385fc3062a000fb7e4b4d0d12e74579f8830ede20d10829496032b2cc56743287f9a9b4d5091877a82fea44deb2cffac8a379f78a151d99e28cbc74d732c083bf06d50584e3f18f254e71a48d6ababaf6fff6f425e9be001510dfbe6a32a27792c00ada036b62ddb90c706d7b882c76a7072f5dd11c69a1f49d4ba183cb0b57545419fa27b9b9706098848935ae9c9e8fbe9fac165d1339128b991a73d20e7795e8d6a8c6adfbf20bf13ada43f2aef3ba78c14697910507132623f721387dce60c4707225b84d9782d469a5d9eaa099f35d6a590ef142ddef766495cf3337815ceef5ff2b3ed352637e72b5c23a2a8ff7d7440236a19b981d47f8e519a0431ebfbc0b78d8a36798b4c060c0c6793499f1e2e818862560a5b501c8d02ba1517be1941da2af5b174e0189c62978d878eb0f9c9db3a9221c28fb94645cf6e85ff2eea8c65ba3083a7382b131b83102dd67aa5453ad7375a4eb8c69fc479fbd29dab8924f801d253f2c997120b705c6e5217fb74702e2f1038917dd5fb0eeb7ae1bf7a668fc7d50c034b4cd5a057a8482e6bc9c921297f44e76967265623a167cd9883eb6e64bc77856dc333bd605d7df3bed0e5cecb5a99fe8b62873d58530f") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.QUICClientHello(context.Background(), &metadata, pkt) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolQUIC) + require.Equal(t, metadata.Client, C.ClientSafari) + require.Equal(t, metadata.Domain, "www.google.com") +} + +func FuzzSniffQUIC(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + var metadata adapter.InboundContext + err := sniff.QUICClientHello(context.Background(), &metadata, data) + require.Error(t, err) + }) +} diff --git a/common/sniff/rdp.go b/common/sniff/rdp.go new file mode 100644 index 00000000..37551fef --- /dev/null +++ b/common/sniff/rdp.go @@ -0,0 +1,91 @@ +package sniff + +import ( + "context" + "encoding/binary" + "io" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/rw" +) + +func RDP(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error { + var tpktVersion uint8 + err := binary.Read(reader, binary.BigEndian, &tpktVersion) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if tpktVersion != 0x03 { + return os.ErrInvalid + } + + var tpktReserved uint8 + err = binary.Read(reader, binary.BigEndian, &tpktReserved) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if tpktReserved != 0x00 { + return os.ErrInvalid + } + + var tpktLength uint16 + err = binary.Read(reader, binary.BigEndian, &tpktLength) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + + if tpktLength != 19 { + return os.ErrInvalid + } + + var cotpLength uint8 + err = binary.Read(reader, binary.BigEndian, &cotpLength) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + + if cotpLength != 14 { + return os.ErrInvalid + } + + var cotpTpduType uint8 + err = binary.Read(reader, binary.BigEndian, &cotpTpduType) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if cotpTpduType != 0xE0 { + return os.ErrInvalid + } + + err = rw.SkipN(reader, 5) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + + var rdpType uint8 + err = binary.Read(reader, binary.BigEndian, &rdpType) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if rdpType != 0x01 { + return os.ErrInvalid + } + var rdpFlags uint8 + err = binary.Read(reader, binary.BigEndian, &rdpFlags) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + var rdpLength uint8 + err = binary.Read(reader, binary.BigEndian, &rdpLength) + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + if rdpLength != 8 { + return os.ErrInvalid + } + metadata.Protocol = C.ProtocolRDP + return nil +} diff --git a/common/sniff/rdp_test.go b/common/sniff/rdp_test.go new file mode 100644 index 00000000..06fa3ab2 --- /dev/null +++ b/common/sniff/rdp_test.go @@ -0,0 +1,25 @@ +package sniff_test + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffRDP(t *testing.T) { + t.Parallel() + + pkt, err := hex.DecodeString("030000130ee00000000000010008000b000000010008000b000000") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.RDP(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.NoError(t, err) + require.Equal(t, C.ProtocolRDP, metadata.Protocol) +} diff --git a/common/sniff/sniff.go b/common/sniff/sniff.go new file mode 100644 index 00000000..b3651e1f --- /dev/null +++ b/common/sniff/sniff.go @@ -0,0 +1,88 @@ +package sniff + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" +) + +type ( + StreamSniffer = func(ctx context.Context, metadata *adapter.InboundContext, reader io.Reader) error + PacketSniffer = func(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error +) + +var ErrNeedMoreData = E.New("need more data") + +func Skip(metadata *adapter.InboundContext) bool { + // skip server first protocols + switch metadata.Destination.Port { + case 25, 465, 587: + // SMTP + return true + case 143, 993: + // IMAP + return true + case 110, 995: + // POP3 + return true + } + return false +} + +func PeekStream(ctx context.Context, metadata *adapter.InboundContext, conn net.Conn, buffers []*buf.Buffer, buffer *buf.Buffer, timeout time.Duration, sniffers ...StreamSniffer) error { + if timeout == 0 { + timeout = C.ReadPayloadTimeout + } + deadline := time.Now().Add(timeout) + var sniffError error + for i := 0; ; i++ { + err := conn.SetReadDeadline(deadline) + if err != nil { + return E.Cause(err, "set read deadline") + } + _, err = buffer.ReadOnceFrom(conn) + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + if i > 0 { + break + } + return E.Cause(err, "read payload") + } + sniffError = nil + for _, sniffer := range sniffers { + reader := io.MultiReader(common.Map(append(buffers, buffer), func(it *buf.Buffer) io.Reader { + return bytes.NewReader(it.Bytes()) + })...) + err = sniffer(ctx, metadata, reader) + if err == nil { + return nil + } + sniffError = E.Errors(sniffError, err) + } + if !errors.Is(sniffError, ErrNeedMoreData) { + break + } + } + return sniffError +} + +func PeekPacket(ctx context.Context, metadata *adapter.InboundContext, packet []byte, sniffers ...PacketSniffer) error { + var sniffError []error + for _, sniffer := range sniffers { + err := sniffer(ctx, metadata, packet) + if err == nil { + return nil + } + sniffError = append(sniffError, err) + } + return E.Errors(sniffError...) +} diff --git a/common/sniff/ssh.go b/common/sniff/ssh.go new file mode 100644 index 00000000..dce5d54f --- /dev/null +++ b/common/sniff/ssh.go @@ -0,0 +1,31 @@ +package sniff + +import ( + "bufio" + "context" + "io" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +func SSH(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error { + const sshPrefix = "SSH-2.0-" + bReader := bufio.NewReader(reader) + prefix, err := bReader.Peek(len(sshPrefix)) + if string(prefix[:]) != sshPrefix[:len(prefix)] { + return os.ErrInvalid + } + if err != nil { + return E.Cause1(ErrNeedMoreData, err) + } + fistLine, _, err := bReader.ReadLine() + if err != nil { + return err + } + metadata.Protocol = C.ProtocolSSH + metadata.Client = string(fistLine)[8:] + return nil +} diff --git a/common/sniff/ssh_test.go b/common/sniff/ssh_test.go new file mode 100644 index 00000000..7cea5aab --- /dev/null +++ b/common/sniff/ssh_test.go @@ -0,0 +1,47 @@ +package sniff_test + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffSSH(t *testing.T) { + t.Parallel() + + pkt, err := hex.DecodeString("5353482d322e302d64726f70626561720d0a000001a40a1492892570d1223aef61b0d647972c8bd30000009f637572766532353531392d7368613235362c637572766532353531392d736861323536406c69627373682e6f72672c6469666669652d68656c6c6d616e2d67726f757031342d7368613235362c6469666669652d68656c6c6d616e2d67726f757031342d736861312c6b6578677565737332406d6174742e7563632e61736e2e61752c6b65782d7374726963742d732d763030406f70656e7373682e636f6d000000207373682d656432353531392c7273612d736861322d3235362c7373682d7273610000003363686163686132302d706f6c7931333035406f70656e7373682e636f6d2c6165733132382d6374722c6165733235362d6374720000003363686163686132302d706f6c7931333035406f70656e7373682e636f6d2c6165733132382d6374722c6165733235362d63747200000017686d61632d736861312c686d61632d736861322d32353600000017686d61632d736861312c686d61632d736861322d323536000000046e6f6e65000000046e6f6e65000000000000000000000000002aa6ed090585b7d635b6") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.SSH(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.NoError(t, err) + require.Equal(t, C.ProtocolSSH, metadata.Protocol) + require.Equal(t, "dropbear", metadata.Client) +} + +func TestSniffIncompleteSSH(t *testing.T) { + t.Parallel() + + pkt, err := hex.DecodeString("5353482d322e30") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.SSH(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.ErrorIs(t, err, sniff.ErrNeedMoreData) +} + +func TestSniffNotSSH(t *testing.T) { + t.Parallel() + + pkt, err := hex.DecodeString("5353482d322e31") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.SSH(context.TODO(), &metadata, bytes.NewReader(pkt)) + require.NotEmpty(t, err) + require.NotErrorIs(t, err, sniff.ErrNeedMoreData) +} diff --git a/common/sniff/stun.go b/common/sniff/stun.go new file mode 100644 index 00000000..dfd11259 --- /dev/null +++ b/common/sniff/stun.go @@ -0,0 +1,25 @@ +package sniff + +import ( + "context" + "encoding/binary" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" +) + +func STUNMessage(_ context.Context, metadata *adapter.InboundContext, packet []byte) error { + pLen := len(packet) + if pLen < 20 { + return os.ErrInvalid + } + if binary.BigEndian.Uint32(packet[4:8]) != 0x2112A442 { + return os.ErrInvalid + } + if len(packet) < 20+int(binary.BigEndian.Uint16(packet[2:4])) { + return os.ErrInvalid + } + metadata.Protocol = C.ProtocolSTUN + return nil +} diff --git a/common/sniff/stun_test.go b/common/sniff/stun_test.go new file mode 100644 index 00000000..f465763e --- /dev/null +++ b/common/sniff/stun_test.go @@ -0,0 +1,32 @@ +package sniff_test + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + + "github.com/stretchr/testify/require" +) + +func TestSniffSTUN(t *testing.T) { + t.Parallel() + packet, err := hex.DecodeString("000100002112a44224b1a025d0c180c484341306") + require.NoError(t, err) + var metadata adapter.InboundContext + err = sniff.STUNMessage(context.Background(), &metadata, packet) + require.NoError(t, err) + require.Equal(t, metadata.Protocol, C.ProtocolSTUN) +} + +func FuzzSniffSTUN(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + var metadata adapter.InboundContext + if err := sniff.STUNMessage(context.Background(), &metadata, data); err == nil { + t.Fail() + } + }) +} diff --git a/common/sniff/tls.go b/common/sniff/tls.go new file mode 100644 index 00000000..613086e8 --- /dev/null +++ b/common/sniff/tls.go @@ -0,0 +1,33 @@ +package sniff + +import ( + "context" + "crypto/tls" + "errors" + "io" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" +) + +func TLSClientHello(ctx context.Context, metadata *adapter.InboundContext, reader io.Reader) error { + var clientHello *tls.ClientHelloInfo + err := tls.Server(bufio.NewReadOnlyConn(reader), &tls.Config{ + GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) { + clientHello = argHello + return nil, nil + }, + }).HandshakeContext(ctx) + if clientHello != nil { + metadata.Protocol = C.ProtocolTLS + metadata.Domain = clientHello.ServerName + return nil + } + if errors.Is(err, io.ErrUnexpectedEOF) { + return E.Cause1(ErrNeedMoreData, err) + } else { + return err + } +} diff --git a/common/srs/binary.go b/common/srs/binary.go new file mode 100644 index 00000000..d5b644ae --- /dev/null +++ b/common/srs/binary.go @@ -0,0 +1,704 @@ +package srs + +import ( + "bufio" + "compress/zlib" + "encoding/binary" + "io" + "net/netip" + "unsafe" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/domain" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/varbin" + + "go4.org/netipx" +) + +var MagicBytes = [3]byte{0x53, 0x52, 0x53} // SRS + +const ( + ruleItemQueryType uint8 = iota + ruleItemNetwork + ruleItemDomain + ruleItemDomainKeyword + ruleItemDomainRegex + ruleItemSourceIPCIDR + ruleItemIPCIDR + ruleItemSourcePort + ruleItemSourcePortRange + ruleItemPort + ruleItemPortRange + ruleItemProcessName + ruleItemProcessPath + ruleItemPackageName + ruleItemWIFISSID + ruleItemWIFIBSSID + ruleItemAdGuardDomain + ruleItemProcessPathRegex + ruleItemNetworkType + ruleItemNetworkIsExpensive + ruleItemNetworkIsConstrained + ruleItemNetworkInterfaceAddress + ruleItemDefaultInterfaceAddress + ruleItemPackageNameRegex + ruleItemFinal uint8 = 0xFF +) + +func Read(reader io.Reader, recover bool) (ruleSetCompat option.PlainRuleSetCompat, err error) { + var magicBytes [3]byte + _, err = io.ReadFull(reader, magicBytes[:]) + if err != nil { + return + } + if magicBytes != MagicBytes { + err = E.New("invalid sing-box rule-set file") + return + } + var version uint8 + err = binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return ruleSetCompat, err + } + if version > C.RuleSetVersionCurrent { + return ruleSetCompat, E.New("unsupported version: ", version) + } + compressReader, err := zlib.NewReader(reader) + if err != nil { + return + } + bReader := bufio.NewReader(compressReader) + length, err := binary.ReadUvarint(bReader) + if err != nil { + return + } + ruleSetCompat.Version = version + ruleSetCompat.Options.Rules = make([]option.HeadlessRule, length) + for i := uint64(0); i < length; i++ { + ruleSetCompat.Options.Rules[i], err = readRule(bReader, recover) + if err != nil { + err = E.Cause(err, "read rule[", i, "]") + return + } + } + return +} + +func Write(writer io.Writer, ruleSet option.PlainRuleSet, generateVersion uint8) error { + _, err := writer.Write(MagicBytes[:]) + if err != nil { + return err + } + err = binary.Write(writer, binary.BigEndian, generateVersion) + if err != nil { + return err + } + compressWriter, err := zlib.NewWriterLevel(writer, zlib.BestCompression) + if err != nil { + return err + } + bWriter := bufio.NewWriter(compressWriter) + _, err = varbin.WriteUvarint(bWriter, uint64(len(ruleSet.Rules))) + if err != nil { + return err + } + for _, rule := range ruleSet.Rules { + err = writeRule(bWriter, rule, generateVersion) + if err != nil { + return err + } + } + err = bWriter.Flush() + if err != nil { + return err + } + return compressWriter.Close() +} + +func readRule(reader varbin.Reader, recover bool) (rule option.HeadlessRule, err error) { + var ruleType uint8 + err = binary.Read(reader, binary.BigEndian, &ruleType) + if err != nil { + return + } + switch ruleType { + case 0: + rule.Type = C.RuleTypeDefault + rule.DefaultOptions, err = readDefaultRule(reader, recover) + case 1: + rule.Type = C.RuleTypeLogical + rule.LogicalOptions, err = readLogicalRule(reader, recover) + default: + err = E.New("unknown rule type: ", ruleType) + } + return +} + +func writeRule(writer varbin.Writer, rule option.HeadlessRule, generateVersion uint8) error { + switch rule.Type { + case C.RuleTypeDefault: + return writeDefaultRule(writer, rule.DefaultOptions, generateVersion) + case C.RuleTypeLogical: + return writeLogicalRule(writer, rule.LogicalOptions, generateVersion) + default: + panic("unknown rule type: " + rule.Type) + } +} + +func readDefaultRule(reader varbin.Reader, recover bool) (rule option.DefaultHeadlessRule, err error) { + var lastItemType uint8 + for { + var itemType uint8 + err = binary.Read(reader, binary.BigEndian, &itemType) + if err != nil { + return + } + switch itemType { + case ruleItemQueryType: + var rawQueryType []uint16 + rawQueryType, err = readRuleItemUint16(reader) + if err != nil { + return + } + rule.QueryType = common.Map(rawQueryType, func(it uint16) option.DNSQueryType { + return option.DNSQueryType(it) + }) + case ruleItemNetwork: + rule.Network, err = readRuleItemString(reader) + case ruleItemDomain: + var matcher *domain.Matcher + matcher, err = domain.ReadMatcher(reader) + if err != nil { + return + } + rule.DomainMatcher = matcher + if recover { + rule.Domain, rule.DomainSuffix = matcher.Dump() + } + case ruleItemDomainKeyword: + rule.DomainKeyword, err = readRuleItemString(reader) + case ruleItemDomainRegex: + rule.DomainRegex, err = readRuleItemString(reader) + case ruleItemSourceIPCIDR: + rule.SourceIPSet, err = readIPSet(reader) + if err != nil { + return + } + if recover { + rule.SourceIPCIDR = common.Map(rule.SourceIPSet.Prefixes(), netip.Prefix.String) + } + case ruleItemIPCIDR: + rule.IPSet, err = readIPSet(reader) + if err != nil { + return + } + if recover { + rule.IPCIDR = common.Map(rule.IPSet.Prefixes(), netip.Prefix.String) + } + case ruleItemSourcePort: + rule.SourcePort, err = readRuleItemUint16(reader) + case ruleItemSourcePortRange: + rule.SourcePortRange, err = readRuleItemString(reader) + case ruleItemPort: + rule.Port, err = readRuleItemUint16(reader) + case ruleItemPortRange: + rule.PortRange, err = readRuleItemString(reader) + case ruleItemProcessName: + rule.ProcessName, err = readRuleItemString(reader) + case ruleItemProcessPath: + rule.ProcessPath, err = readRuleItemString(reader) + case ruleItemProcessPathRegex: + rule.ProcessPathRegex, err = readRuleItemString(reader) + case ruleItemPackageName: + rule.PackageName, err = readRuleItemString(reader) + case ruleItemPackageNameRegex: + rule.PackageNameRegex, err = readRuleItemString(reader) + case ruleItemWIFISSID: + rule.WIFISSID, err = readRuleItemString(reader) + case ruleItemWIFIBSSID: + rule.WIFIBSSID, err = readRuleItemString(reader) + case ruleItemAdGuardDomain: + var matcher *domain.AdGuardMatcher + matcher, err = domain.ReadAdGuardMatcher(reader) + if err != nil { + return + } + rule.AdGuardDomainMatcher = matcher + if recover { + rule.AdGuardDomain = matcher.Dump() + } + case ruleItemNetworkType: + rule.NetworkType, err = readRuleItemUint8[option.InterfaceType](reader) + case ruleItemNetworkIsExpensive: + rule.NetworkIsExpensive = true + case ruleItemNetworkIsConstrained: + rule.NetworkIsConstrained = true + case ruleItemNetworkInterfaceAddress: + rule.NetworkInterfaceAddress = new(badjson.TypedMap[option.InterfaceType, badoption.Listable[*badoption.Prefixable]]) + var size uint64 + size, err = binary.ReadUvarint(reader) + if err != nil { + return + } + for i := uint64(0); i < size; i++ { + var key uint8 + err = binary.Read(reader, binary.BigEndian, &key) + if err != nil { + return + } + var value []*badoption.Prefixable + var prefixCount uint64 + prefixCount, err = binary.ReadUvarint(reader) + if err != nil { + return + } + for j := uint64(0); j < prefixCount; j++ { + var prefix netip.Prefix + prefix, err = readPrefix(reader) + if err != nil { + return + } + value = append(value, common.Ptr(badoption.Prefixable(prefix))) + } + rule.NetworkInterfaceAddress.Put(option.InterfaceType(key), value) + } + case ruleItemDefaultInterfaceAddress: + var value []*badoption.Prefixable + var prefixCount uint64 + prefixCount, err = binary.ReadUvarint(reader) + if err != nil { + return + } + for j := uint64(0); j < prefixCount; j++ { + var prefix netip.Prefix + prefix, err = readPrefix(reader) + if err != nil { + return + } + value = append(value, common.Ptr(badoption.Prefixable(prefix))) + } + rule.DefaultInterfaceAddress = value + case ruleItemFinal: + err = binary.Read(reader, binary.BigEndian, &rule.Invert) + return + default: + err = E.New("unknown rule item type: ", itemType, ", last type: ", lastItemType) + } + if err != nil { + return + } + lastItemType = itemType + } +} + +func writeDefaultRule(writer varbin.Writer, rule option.DefaultHeadlessRule, generateVersion uint8) error { + err := binary.Write(writer, binary.BigEndian, uint8(0)) + if err != nil { + return err + } + if len(rule.QueryType) > 0 { + err = writeRuleItemUint16(writer, ruleItemQueryType, common.Map(rule.QueryType, func(it option.DNSQueryType) uint16 { + return uint16(it) + })) + if err != nil { + return err + } + } + if len(rule.Network) > 0 { + err = writeRuleItemString(writer, ruleItemNetwork, rule.Network) + if err != nil { + return err + } + } + if len(rule.Domain) > 0 || len(rule.DomainSuffix) > 0 { + err = binary.Write(writer, binary.BigEndian, ruleItemDomain) + if err != nil { + return err + } + err = domain.NewMatcher(rule.Domain, rule.DomainSuffix, generateVersion == C.RuleSetVersion1).Write(writer) + if err != nil { + return err + } + } + if len(rule.DomainKeyword) > 0 { + err = writeRuleItemString(writer, ruleItemDomainKeyword, rule.DomainKeyword) + if err != nil { + return err + } + } + if len(rule.DomainRegex) > 0 { + err = writeRuleItemString(writer, ruleItemDomainRegex, rule.DomainRegex) + if err != nil { + return err + } + } + if len(rule.SourceIPCIDR) > 0 { + err = writeRuleItemCIDR(writer, ruleItemSourceIPCIDR, rule.SourceIPCIDR) + if err != nil { + return E.Cause(err, "source_ip_cidr") + } + } + if len(rule.IPCIDR) > 0 { + err = writeRuleItemCIDR(writer, ruleItemIPCIDR, rule.IPCIDR) + if err != nil { + return E.Cause(err, "ipcidr") + } + } + if len(rule.SourcePort) > 0 { + err = writeRuleItemUint16(writer, ruleItemSourcePort, rule.SourcePort) + if err != nil { + return err + } + } + if len(rule.SourcePortRange) > 0 { + err = writeRuleItemString(writer, ruleItemSourcePortRange, rule.SourcePortRange) + if err != nil { + return err + } + } + if len(rule.Port) > 0 { + err = writeRuleItemUint16(writer, ruleItemPort, rule.Port) + if err != nil { + return err + } + } + if len(rule.PortRange) > 0 { + err = writeRuleItemString(writer, ruleItemPortRange, rule.PortRange) + if err != nil { + return err + } + } + if len(rule.ProcessName) > 0 { + err = writeRuleItemString(writer, ruleItemProcessName, rule.ProcessName) + if err != nil { + return err + } + } + if len(rule.ProcessPath) > 0 { + err = writeRuleItemString(writer, ruleItemProcessPath, rule.ProcessPath) + if err != nil { + return err + } + } + if len(rule.ProcessPathRegex) > 0 { + err = writeRuleItemString(writer, ruleItemProcessPathRegex, rule.ProcessPathRegex) + if err != nil { + return err + } + } + if len(rule.PackageName) > 0 { + err = writeRuleItemString(writer, ruleItemPackageName, rule.PackageName) + if err != nil { + return err + } + } + if len(rule.PackageNameRegex) > 0 { + if generateVersion < C.RuleSetVersion5 { + return E.New("`package_name_regex` rule item is only supported in version 5 or later") + } + err = writeRuleItemString(writer, ruleItemPackageNameRegex, rule.PackageNameRegex) + if err != nil { + return err + } + } + if len(rule.NetworkType) > 0 { + if generateVersion < C.RuleSetVersion3 { + return E.New("`network_type` rule item is only supported in version 3 or later") + } + err = writeRuleItemUint8(writer, ruleItemNetworkType, rule.NetworkType) + if err != nil { + return err + } + } + if rule.NetworkIsExpensive { + if generateVersion < C.RuleSetVersion3 { + return E.New("`network_is_expensive` rule item is only supported in version 3 or later") + } + err = binary.Write(writer, binary.BigEndian, ruleItemNetworkIsExpensive) + if err != nil { + return err + } + } + if rule.NetworkIsConstrained { + if generateVersion < C.RuleSetVersion3 { + return E.New("`network_is_constrained` rule item is only supported in version 3 or later") + } + err = binary.Write(writer, binary.BigEndian, ruleItemNetworkIsConstrained) + if err != nil { + return err + } + } + if rule.NetworkInterfaceAddress != nil && rule.NetworkInterfaceAddress.Size() > 0 { + if generateVersion < C.RuleSetVersion4 { + return E.New("`network_interface_address` rule item is only supported in version 4 or later") + } + err = writer.WriteByte(ruleItemNetworkInterfaceAddress) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(rule.NetworkInterfaceAddress.Size())) + if err != nil { + return err + } + for _, entry := range rule.NetworkInterfaceAddress.Entries() { + err = binary.Write(writer, binary.BigEndian, uint8(entry.Key.Build())) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(entry.Value))) + if err != nil { + return err + } + for _, rawPrefix := range entry.Value { + err = writePrefix(writer, rawPrefix.Build(netip.Prefix{})) + if err != nil { + return err + } + } + } + } + if len(rule.DefaultInterfaceAddress) > 0 { + if generateVersion < C.RuleSetVersion4 { + return E.New("`default_interface_address` rule item is only supported in version 4 or later") + } + err = writer.WriteByte(ruleItemDefaultInterfaceAddress) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(rule.DefaultInterfaceAddress))) + if err != nil { + return err + } + for _, rawPrefix := range rule.DefaultInterfaceAddress { + err = writePrefix(writer, rawPrefix.Build(netip.Prefix{})) + if err != nil { + return err + } + } + } + if len(rule.WIFISSID) > 0 { + err = writeRuleItemString(writer, ruleItemWIFISSID, rule.WIFISSID) + if err != nil { + return err + } + } + if len(rule.WIFIBSSID) > 0 { + err = writeRuleItemString(writer, ruleItemWIFIBSSID, rule.WIFIBSSID) + if err != nil { + return err + } + } + if len(rule.AdGuardDomain) > 0 { + if generateVersion < C.RuleSetVersion2 { + return E.New("AdGuard rule items is only supported in version 2 or later") + } + err = binary.Write(writer, binary.BigEndian, ruleItemAdGuardDomain) + if err != nil { + return err + } + err = domain.NewAdGuardMatcher(rule.AdGuardDomain).Write(writer) + if err != nil { + return err + } + } + err = binary.Write(writer, binary.BigEndian, ruleItemFinal) + if err != nil { + return err + } + err = binary.Write(writer, binary.BigEndian, rule.Invert) + if err != nil { + return err + } + return nil +} + +func readRuleItemString(reader varbin.Reader) ([]string, error) { + length, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + result := make([]string, length) + for i := range result { + strLen, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + buf := make([]byte, strLen) + _, err = io.ReadFull(reader, buf) + if err != nil { + return nil, err + } + result[i] = string(buf) + } + return result, nil +} + +func writeRuleItemString(writer varbin.Writer, itemType uint8, value []string) error { + err := writer.WriteByte(itemType) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + for _, s := range value { + _, err = varbin.WriteUvarint(writer, uint64(len(s))) + if err != nil { + return err + } + _, err = writer.Write([]byte(s)) + if err != nil { + return err + } + } + return nil +} + +func readRuleItemUint8[E ~uint8](reader varbin.Reader) ([]E, error) { + length, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + result := make([]E, length) + _, err = io.ReadFull(reader, *(*[]byte)(unsafe.Pointer(&result))) + if err != nil { + return nil, err + } + return result, nil +} + +func writeRuleItemUint8[E ~uint8](writer varbin.Writer, itemType uint8, value []E) error { + err := writer.WriteByte(itemType) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + _, err = writer.Write(*(*[]byte)(unsafe.Pointer(&value))) + return err +} + +func readRuleItemUint16(reader varbin.Reader) ([]uint16, error) { + length, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + result := make([]uint16, length) + err = binary.Read(reader, binary.BigEndian, result) + if err != nil { + return nil, err + } + return result, nil +} + +func writeRuleItemUint16(writer varbin.Writer, itemType uint8, value []uint16) error { + err := writer.WriteByte(itemType) + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + return binary.Write(writer, binary.BigEndian, value) +} + +func writeRuleItemCIDR(writer varbin.Writer, itemType uint8, value []string) error { + var builder netipx.IPSetBuilder + for i, prefixString := range value { + prefix, err := netip.ParsePrefix(prefixString) + if err == nil { + builder.AddPrefix(prefix) + continue + } + addr, addrErr := netip.ParseAddr(prefixString) + if addrErr == nil { + builder.Add(addr) + continue + } + return E.Cause(err, "parse [", i, "]") + } + ipSet, err := builder.IPSet() + if err != nil { + return err + } + err = binary.Write(writer, binary.BigEndian, itemType) + if err != nil { + return err + } + return writeIPSet(writer, ipSet) +} + +func readLogicalRule(reader varbin.Reader, recovery bool) (logicalRule option.LogicalHeadlessRule, err error) { + mode, err := reader.ReadByte() + if err != nil { + return + } + switch mode { + case 0: + logicalRule.Mode = C.LogicalTypeAnd + case 1: + logicalRule.Mode = C.LogicalTypeOr + default: + err = E.New("unknown logical mode: ", mode) + return + } + length, err := binary.ReadUvarint(reader) + if err != nil { + return + } + logicalRule.Rules = make([]option.HeadlessRule, length) + for i := uint64(0); i < length; i++ { + logicalRule.Rules[i], err = readRule(reader, recovery) + if err != nil { + err = E.Cause(err, "read logical rule [", i, "]") + return + } + } + err = binary.Read(reader, binary.BigEndian, &logicalRule.Invert) + if err != nil { + return + } + return +} + +func writeLogicalRule(writer varbin.Writer, logicalRule option.LogicalHeadlessRule, generateVersion uint8) error { + err := binary.Write(writer, binary.BigEndian, uint8(1)) + if err != nil { + return err + } + switch logicalRule.Mode { + case C.LogicalTypeAnd: + err = binary.Write(writer, binary.BigEndian, uint8(0)) + case C.LogicalTypeOr: + err = binary.Write(writer, binary.BigEndian, uint8(1)) + default: + panic("unknown logical mode: " + logicalRule.Mode) + } + if err != nil { + return err + } + _, err = varbin.WriteUvarint(writer, uint64(len(logicalRule.Rules))) + if err != nil { + return err + } + for _, rule := range logicalRule.Rules { + err = writeRule(writer, rule, generateVersion) + if err != nil { + return err + } + } + err = binary.Write(writer, binary.BigEndian, logicalRule.Invert) + if err != nil { + return err + } + return nil +} diff --git a/common/srs/compat_test.go b/common/srs/compat_test.go new file mode 100644 index 00000000..98552b32 --- /dev/null +++ b/common/srs/compat_test.go @@ -0,0 +1,494 @@ +package srs + +import ( + "bufio" + "bytes" + "encoding/binary" + "net/netip" + "strings" + "testing" + "unsafe" + + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/varbin" + + "github.com/stretchr/testify/require" + "go4.org/netipx" +) + +// Old implementations using varbin reflection-based serialization + +func oldWriteStringSlice(writer varbin.Writer, value []string) error { + //nolint:staticcheck + return varbin.Write(writer, binary.BigEndian, value) +} + +func oldReadStringSlice(reader varbin.Reader) ([]string, error) { + //nolint:staticcheck + return varbin.ReadValue[[]string](reader, binary.BigEndian) +} + +func oldWriteUint8Slice[E ~uint8](writer varbin.Writer, value []E) error { + //nolint:staticcheck + return varbin.Write(writer, binary.BigEndian, value) +} + +func oldReadUint8Slice[E ~uint8](reader varbin.Reader) ([]E, error) { + //nolint:staticcheck + return varbin.ReadValue[[]E](reader, binary.BigEndian) +} + +func oldWriteUint16Slice(writer varbin.Writer, value []uint16) error { + //nolint:staticcheck + return varbin.Write(writer, binary.BigEndian, value) +} + +func oldReadUint16Slice(reader varbin.Reader) ([]uint16, error) { + //nolint:staticcheck + return varbin.ReadValue[[]uint16](reader, binary.BigEndian) +} + +func oldWritePrefix(writer varbin.Writer, prefix netip.Prefix) error { + //nolint:staticcheck + err := varbin.Write(writer, binary.BigEndian, prefix.Addr().AsSlice()) + if err != nil { + return err + } + return binary.Write(writer, binary.BigEndian, uint8(prefix.Bits())) +} + +type oldIPRangeData struct { + From []byte + To []byte +} + +// Note: The old writeIPSet had a bug where varbin.Write(writer, binary.BigEndian, data) +// with a struct VALUE (not pointer) silently wrote nothing because field.CanSet() returned false. +// This caused IP range data to be missing from the output. +// The new implementation correctly writes all range data. +// +// The old readIPSet used varbin.Read with a pre-allocated slice, which worked because +// slice elements are addressable and CanSet() returns true for them. +// +// For compatibility testing, we verify: +// 1. New write produces correct output with range data +// 2. New read can parse the new format correctly +// 3. Round-trip works correctly + +func oldReadIPSet(reader varbin.Reader) (*netipx.IPSet, error) { + version, err := reader.ReadByte() + if err != nil { + return nil, err + } + if version != 1 { + return nil, err + } + var length uint64 + err = binary.Read(reader, binary.BigEndian, &length) + if err != nil { + return nil, err + } + ranges := make([]oldIPRangeData, length) + //nolint:staticcheck + err = varbin.Read(reader, binary.BigEndian, &ranges) + if err != nil { + return nil, err + } + mySet := &myIPSet{ + rr: make([]myIPRange, len(ranges)), + } + for i, rangeData := range ranges { + mySet.rr[i].from = M.AddrFromIP(rangeData.From) + mySet.rr[i].to = M.AddrFromIP(rangeData.To) + } + return (*netipx.IPSet)(unsafe.Pointer(mySet)), nil +} + +// New write functions (without itemType prefix for testing) + +func newWriteStringSlice(writer varbin.Writer, value []string) error { + _, err := varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + for _, s := range value { + _, err = varbin.WriteUvarint(writer, uint64(len(s))) + if err != nil { + return err + } + _, err = writer.Write([]byte(s)) + if err != nil { + return err + } + } + return nil +} + +func newWriteUint8Slice[E ~uint8](writer varbin.Writer, value []E) error { + _, err := varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + _, err = writer.Write(*(*[]byte)(unsafe.Pointer(&value))) + return err +} + +func newWriteUint16Slice(writer varbin.Writer, value []uint16) error { + _, err := varbin.WriteUvarint(writer, uint64(len(value))) + if err != nil { + return err + } + return binary.Write(writer, binary.BigEndian, value) +} + +func newWritePrefix(writer varbin.Writer, prefix netip.Prefix) error { + addrSlice := prefix.Addr().AsSlice() + _, err := varbin.WriteUvarint(writer, uint64(len(addrSlice))) + if err != nil { + return err + } + _, err = writer.Write(addrSlice) + if err != nil { + return err + } + return writer.WriteByte(uint8(prefix.Bits())) +} + +// Tests + +func TestStringSliceCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input []string + }{ + {"nil", nil}, + {"empty", []string{}}, + {"single_empty", []string{""}}, + {"single", []string{"test"}}, + {"multi", []string{"a", "b", "c"}}, + {"with_empty", []string{"a", "", "c"}}, + {"utf8", []string{"测试", "テスト", "тест"}}, + {"long_string", []string{strings.Repeat("x", 128)}}, + {"many_elements", generateStrings(128)}, + {"many_elements_256", generateStrings(256)}, + {"127_byte_string", []string{strings.Repeat("x", 127)}}, + {"128_byte_string", []string{strings.Repeat("x", 128)}}, + {"mixed_lengths", []string{"a", strings.Repeat("b", 100), "", strings.Repeat("c", 200)}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Old write + var oldBuf bytes.Buffer + err := oldWriteStringSlice(&oldBuf, tc.input) + require.NoError(t, err) + + // New write + var newBuf bytes.Buffer + err = newWriteStringSlice(&newBuf, tc.input) + require.NoError(t, err) + + // Bytes must match + require.Equal(t, oldBuf.Bytes(), newBuf.Bytes(), + "mismatch for %q\nold: %x\nnew: %x", tc.name, oldBuf.Bytes(), newBuf.Bytes()) + + // New write -> old read + readBack, err := oldReadStringSlice(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + requireStringSliceEqual(t, tc.input, readBack) + + // Old write -> new read + readBack2, err := readRuleItemString(bufio.NewReader(bytes.NewReader(oldBuf.Bytes()))) + require.NoError(t, err) + requireStringSliceEqual(t, tc.input, readBack2) + }) + } +} + +func TestUint8SliceCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input []uint8 + }{ + {"nil", nil}, + {"empty", []uint8{}}, + {"single_zero", []uint8{0}}, + {"single_max", []uint8{255}}, + {"multi", []uint8{0, 1, 127, 128, 255}}, + {"boundary", []uint8{0x00, 0x7f, 0x80, 0xff}}, + {"sequential", generateUint8Slice(256)}, + {"127_elements", generateUint8Slice(127)}, + {"128_elements", generateUint8Slice(128)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Old write + var oldBuf bytes.Buffer + err := oldWriteUint8Slice(&oldBuf, tc.input) + require.NoError(t, err) + + // New write + var newBuf bytes.Buffer + err = newWriteUint8Slice(&newBuf, tc.input) + require.NoError(t, err) + + // Bytes must match + require.Equal(t, oldBuf.Bytes(), newBuf.Bytes(), + "mismatch for %q\nold: %x\nnew: %x", tc.name, oldBuf.Bytes(), newBuf.Bytes()) + + // New write -> old read + readBack, err := oldReadUint8Slice[uint8](bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + requireUint8SliceEqual(t, tc.input, readBack) + + // Old write -> new read + readBack2, err := readRuleItemUint8[uint8](bufio.NewReader(bytes.NewReader(oldBuf.Bytes()))) + require.NoError(t, err) + requireUint8SliceEqual(t, tc.input, readBack2) + }) + } +} + +func TestUint16SliceCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input []uint16 + }{ + {"nil", nil}, + {"empty", []uint16{}}, + {"single_zero", []uint16{0}}, + {"single_max", []uint16{65535}}, + {"multi", []uint16{0, 255, 256, 32767, 32768, 65535}}, + {"ports", []uint16{80, 443, 8080, 8443}}, + {"127_elements", generateUint16Slice(127)}, + {"128_elements", generateUint16Slice(128)}, + {"256_elements", generateUint16Slice(256)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Old write + var oldBuf bytes.Buffer + err := oldWriteUint16Slice(&oldBuf, tc.input) + require.NoError(t, err) + + // New write + var newBuf bytes.Buffer + err = newWriteUint16Slice(&newBuf, tc.input) + require.NoError(t, err) + + // Bytes must match + require.Equal(t, oldBuf.Bytes(), newBuf.Bytes(), + "mismatch for %q\nold: %x\nnew: %x", tc.name, oldBuf.Bytes(), newBuf.Bytes()) + + // New write -> old read + readBack, err := oldReadUint16Slice(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + requireUint16SliceEqual(t, tc.input, readBack) + + // Old write -> new read + readBack2, err := readRuleItemUint16(bufio.NewReader(bytes.NewReader(oldBuf.Bytes()))) + require.NoError(t, err) + requireUint16SliceEqual(t, tc.input, readBack2) + }) + } +} + +func TestPrefixCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input netip.Prefix + }{ + {"ipv4_0", netip.MustParsePrefix("0.0.0.0/0")}, + {"ipv4_8", netip.MustParsePrefix("10.0.0.0/8")}, + {"ipv4_16", netip.MustParsePrefix("192.168.0.0/16")}, + {"ipv4_24", netip.MustParsePrefix("192.168.1.0/24")}, + {"ipv4_32", netip.MustParsePrefix("1.2.3.4/32")}, + {"ipv6_0", netip.MustParsePrefix("::/0")}, + {"ipv6_64", netip.MustParsePrefix("2001:db8::/64")}, + {"ipv6_128", netip.MustParsePrefix("::1/128")}, + {"ipv6_full", netip.MustParsePrefix("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128")}, + {"ipv4_private", netip.MustParsePrefix("172.16.0.0/12")}, + {"ipv6_link_local", netip.MustParsePrefix("fe80::/10")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Old write + var oldBuf bytes.Buffer + err := oldWritePrefix(&oldBuf, tc.input) + require.NoError(t, err) + + // New write + var newBuf bytes.Buffer + err = newWritePrefix(&newBuf, tc.input) + require.NoError(t, err) + + // Bytes must match + require.Equal(t, oldBuf.Bytes(), newBuf.Bytes(), + "mismatch for %q\nold: %x\nnew: %x", tc.name, oldBuf.Bytes(), newBuf.Bytes()) + + // New write -> new read (no old read for prefix) + readBack, err := readPrefix(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + require.Equal(t, tc.input, readBack) + + // Old write -> new read + readBack2, err := readPrefix(bufio.NewReader(bytes.NewReader(oldBuf.Bytes()))) + require.NoError(t, err) + require.Equal(t, tc.input, readBack2) + }) + } +} + +func TestIPSetCompat(t *testing.T) { + t.Parallel() + + // Note: The old writeIPSet was buggy (varbin.Write with struct values wrote nothing). + // This test verifies the new implementation writes correct data and round-trips correctly. + + cases := []struct { + name string + input *netipx.IPSet + }{ + {"single_ipv4", buildIPSet("1.2.3.4")}, + {"ipv4_range", buildIPSet("192.168.0.0/16")}, + {"multi_ipv4", buildIPSet("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")}, + {"single_ipv6", buildIPSet("::1")}, + {"ipv6_range", buildIPSet("2001:db8::/32")}, + {"mixed", buildIPSet("10.0.0.0/8", "::1", "2001:db8::/32")}, + {"large", buildLargeIPSet(100)}, + {"adjacent_ranges", buildIPSet("192.168.0.0/24", "192.168.1.0/24", "192.168.2.0/24")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // New write + var newBuf bytes.Buffer + err := writeIPSet(&newBuf, tc.input) + require.NoError(t, err) + + // Verify format starts with version byte (1) + uint64 count + require.True(t, len(newBuf.Bytes()) >= 9, "output too short") + require.Equal(t, byte(1), newBuf.Bytes()[0], "version byte mismatch") + + // New write -> old read (varbin.Read with pre-allocated slice works correctly) + readBack, err := oldReadIPSet(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + requireIPSetEqual(t, tc.input, readBack) + + // New write -> new read + readBack2, err := readIPSet(bufio.NewReader(bytes.NewReader(newBuf.Bytes()))) + require.NoError(t, err) + requireIPSetEqual(t, tc.input, readBack2) + }) + } +} + +// Helper functions + +func generateStrings(count int) []string { + result := make([]string, count) + for i := range result { + result[i] = strings.Repeat("x", i%50) + } + return result +} + +func generateUint8Slice(count int) []uint8 { + result := make([]uint8, count) + for i := range result { + result[i] = uint8(i % 256) + } + return result +} + +func generateUint16Slice(count int) []uint16 { + result := make([]uint16, count) + for i := range result { + result[i] = uint16(i * 257) + } + return result +} + +func buildIPSet(cidrs ...string) *netipx.IPSet { + var builder netipx.IPSetBuilder + for _, cidr := range cidrs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + addr, err := netip.ParseAddr(cidr) + if err != nil { + panic(err) + } + builder.Add(addr) + } else { + builder.AddPrefix(prefix) + } + } + set, _ := builder.IPSet() + return set +} + +func buildLargeIPSet(count int) *netipx.IPSet { + var builder netipx.IPSetBuilder + for i := 0; i < count; i++ { + prefix := netip.PrefixFrom(netip.AddrFrom4([4]byte{10, byte(i / 256), byte(i % 256), 0}), 24) + builder.AddPrefix(prefix) + } + set, _ := builder.IPSet() + return set +} + +func requireStringSliceEqual(t *testing.T, expected, actual []string) { + t.Helper() + if len(expected) == 0 && len(actual) == 0 { + return + } + require.Equal(t, expected, actual) +} + +func requireUint8SliceEqual(t *testing.T, expected, actual []uint8) { + t.Helper() + if len(expected) == 0 && len(actual) == 0 { + return + } + require.Equal(t, expected, actual) +} + +func requireUint16SliceEqual(t *testing.T, expected, actual []uint16) { + t.Helper() + if len(expected) == 0 && len(actual) == 0 { + return + } + require.Equal(t, expected, actual) +} + +func requireIPSetEqual(t *testing.T, expected, actual *netipx.IPSet) { + t.Helper() + expectedRanges := expected.Ranges() + actualRanges := actual.Ranges() + require.Equal(t, len(expectedRanges), len(actualRanges), "range count mismatch") + for i := range expectedRanges { + require.Equal(t, expectedRanges[i].From(), actualRanges[i].From(), "range[%d].from mismatch", i) + require.Equal(t, expectedRanges[i].To(), actualRanges[i].To(), "range[%d].to mismatch", i) + } +} diff --git a/common/srs/ip_cidr.go b/common/srs/ip_cidr.go new file mode 100644 index 00000000..7c81abda --- /dev/null +++ b/common/srs/ip_cidr.go @@ -0,0 +1,44 @@ +package srs + +import ( + "encoding/binary" + "io" + "net/netip" + + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/varbin" +) + +func readPrefix(reader varbin.Reader) (netip.Prefix, error) { + addrLen, err := binary.ReadUvarint(reader) + if err != nil { + return netip.Prefix{}, err + } + addrSlice := make([]byte, addrLen) + _, err = io.ReadFull(reader, addrSlice) + if err != nil { + return netip.Prefix{}, err + } + prefixBits, err := reader.ReadByte() + if err != nil { + return netip.Prefix{}, err + } + return netip.PrefixFrom(M.AddrFromIP(addrSlice), int(prefixBits)), nil +} + +func writePrefix(writer varbin.Writer, prefix netip.Prefix) error { + addrSlice := prefix.Addr().AsSlice() + _, err := varbin.WriteUvarint(writer, uint64(len(addrSlice))) + if err != nil { + return err + } + _, err = writer.Write(addrSlice) + if err != nil { + return err + } + err = writer.WriteByte(uint8(prefix.Bits())) + if err != nil { + return err + } + return nil +} diff --git a/common/srs/ip_set.go b/common/srs/ip_set.go new file mode 100644 index 00000000..a10ac08c --- /dev/null +++ b/common/srs/ip_set.go @@ -0,0 +1,98 @@ +package srs + +import ( + "encoding/binary" + "io" + "net/netip" + "os" + "unsafe" + + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/varbin" + + "go4.org/netipx" +) + +type myIPSet struct { + rr []myIPRange +} + +type myIPRange struct { + from netip.Addr + to netip.Addr +} + +func readIPSet(reader varbin.Reader) (*netipx.IPSet, error) { + version, err := reader.ReadByte() + if err != nil { + return nil, err + } + if version != 1 { + return nil, os.ErrInvalid + } + // WTF why using uint64 here + var length uint64 + err = binary.Read(reader, binary.BigEndian, &length) + if err != nil { + return nil, err + } + mySet := &myIPSet{ + rr: make([]myIPRange, length), + } + for i := range mySet.rr { + fromLen, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + fromBytes := make([]byte, fromLen) + _, err = io.ReadFull(reader, fromBytes) + if err != nil { + return nil, err + } + toLen, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + toBytes := make([]byte, toLen) + _, err = io.ReadFull(reader, toBytes) + if err != nil { + return nil, err + } + mySet.rr[i].from = M.AddrFromIP(fromBytes) + mySet.rr[i].to = M.AddrFromIP(toBytes) + } + return (*netipx.IPSet)(unsafe.Pointer(mySet)), nil +} + +func writeIPSet(writer varbin.Writer, set *netipx.IPSet) error { + err := writer.WriteByte(1) + if err != nil { + return err + } + mySet := (*myIPSet)(unsafe.Pointer(set)) + err = binary.Write(writer, binary.BigEndian, uint64(len(mySet.rr))) + if err != nil { + return err + } + for _, rr := range mySet.rr { + fromBytes := rr.from.AsSlice() + _, err = varbin.WriteUvarint(writer, uint64(len(fromBytes))) + if err != nil { + return err + } + _, err = writer.Write(fromBytes) + if err != nil { + return err + } + toBytes := rr.to.AsSlice() + _, err = varbin.WriteUvarint(writer, uint64(len(toBytes))) + if err != nil { + return err + } + _, err = writer.Write(toBytes) + if err != nil { + return err + } + } + return nil +} diff --git a/common/stun/stun.go b/common/stun/stun.go new file mode 100644 index 00000000..a4bb9d5c --- /dev/null +++ b/common/stun/stun.go @@ -0,0 +1,612 @@ +package stun + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/bufio/deadline" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +const ( + DefaultServer = "stun.voipgate.com:3478" + + magicCookie = 0x2112A442 + headerSize = 20 + + bindingRequest = 0x0001 + bindingSuccessResponse = 0x0101 + bindingErrorResponse = 0x0111 + + attrMappedAddress = 0x0001 + attrChangeRequest = 0x0003 + attrErrorCode = 0x0009 + attrXORMappedAddress = 0x0020 + attrOtherAddress = 0x802c + + familyIPv4 = 0x01 + familyIPv6 = 0x02 + + changeIP = 0x04 + changePort = 0x02 + + defaultRTO = 500 * time.Millisecond + minRTO = 250 * time.Millisecond + maxRetransmit = 2 +) + +type Phase int32 + +const ( + PhaseBinding Phase = iota + PhaseNATMapping + PhaseNATFiltering + PhaseDone +) + +type NATMapping int32 + +const ( + NATMappingUnknown NATMapping = iota + _ // reserved + NATMappingEndpointIndependent + NATMappingAddressDependent + NATMappingAddressAndPortDependent +) + +func (m NATMapping) String() string { + switch m { + case NATMappingEndpointIndependent: + return "Endpoint Independent" + case NATMappingAddressDependent: + return "Address Dependent" + case NATMappingAddressAndPortDependent: + return "Address and Port Dependent" + default: + return "Unknown" + } +} + +type NATFiltering int32 + +const ( + NATFilteringUnknown NATFiltering = iota + NATFilteringEndpointIndependent + NATFilteringAddressDependent + NATFilteringAddressAndPortDependent +) + +func (f NATFiltering) String() string { + switch f { + case NATFilteringEndpointIndependent: + return "Endpoint Independent" + case NATFilteringAddressDependent: + return "Address Dependent" + case NATFilteringAddressAndPortDependent: + return "Address and Port Dependent" + default: + return "Unknown" + } +} + +type TransactionID [12]byte + +type Options struct { + Server string + Dialer N.Dialer + Context context.Context + OnProgress func(Progress) +} + +type Progress struct { + Phase Phase + ExternalAddr string + LatencyMs int32 + NATMapping NATMapping + NATFiltering NATFiltering +} + +type Result struct { + ExternalAddr string + LatencyMs int32 + NATMapping NATMapping + NATFiltering NATFiltering + NATTypeSupported bool +} + +type parsedResponse struct { + xorMappedAddr netip.AddrPort + mappedAddr netip.AddrPort + otherAddr netip.AddrPort +} + +func (r *parsedResponse) externalAddr() (netip.AddrPort, bool) { + if r.xorMappedAddr.IsValid() { + return r.xorMappedAddr, true + } + if r.mappedAddr.IsValid() { + return r.mappedAddr, true + } + return netip.AddrPort{}, false +} + +type stunAttribute struct { + typ uint16 + value []byte +} + +func newTransactionID() TransactionID { + var id TransactionID + _, _ = rand.Read(id[:]) + return id +} + +func buildBindingRequest(txID TransactionID, attrs ...stunAttribute) []byte { + attrLen := 0 + for _, attr := range attrs { + attrLen += 4 + len(attr.value) + paddingLen(len(attr.value)) + } + + buf := make([]byte, headerSize+attrLen) + binary.BigEndian.PutUint16(buf[0:2], bindingRequest) + binary.BigEndian.PutUint16(buf[2:4], uint16(attrLen)) + binary.BigEndian.PutUint32(buf[4:8], magicCookie) + copy(buf[8:20], txID[:]) + + offset := headerSize + for _, attr := range attrs { + binary.BigEndian.PutUint16(buf[offset:offset+2], attr.typ) + binary.BigEndian.PutUint16(buf[offset+2:offset+4], uint16(len(attr.value))) + copy(buf[offset+4:offset+4+len(attr.value)], attr.value) + offset += 4 + len(attr.value) + paddingLen(len(attr.value)) + } + + return buf +} + +func changeRequestAttr(flags byte) stunAttribute { + return stunAttribute{ + typ: attrChangeRequest, + value: []byte{0, 0, 0, flags}, + } +} + +func parseResponse(data []byte, expectedTxID TransactionID) (*parsedResponse, error) { + if len(data) < headerSize { + return nil, E.New("response too short") + } + + msgType := binary.BigEndian.Uint16(data[0:2]) + if msgType&0xC000 != 0 { + return nil, E.New("invalid STUN message: top 2 bits not zero") + } + + cookie := binary.BigEndian.Uint32(data[4:8]) + if cookie != magicCookie { + return nil, E.New("invalid magic cookie") + } + + var txID TransactionID + copy(txID[:], data[8:20]) + if txID != expectedTxID { + return nil, E.New("transaction ID mismatch") + } + + msgLen := int(binary.BigEndian.Uint16(data[2:4])) + if msgLen > len(data)-headerSize { + return nil, E.New("message length exceeds data") + } + + attrData := data[headerSize : headerSize+msgLen] + + if msgType == bindingErrorResponse { + return nil, parseErrorResponse(attrData) + } + if msgType != bindingSuccessResponse { + return nil, E.New("unexpected message type: ", fmt.Sprintf("0x%04x", msgType)) + } + + resp := &parsedResponse{} + offset := 0 + for offset+4 <= len(attrData) { + attrType := binary.BigEndian.Uint16(attrData[offset : offset+2]) + attrLen := int(binary.BigEndian.Uint16(attrData[offset+2 : offset+4])) + if offset+4+attrLen > len(attrData) { + break + } + attrValue := attrData[offset+4 : offset+4+attrLen] + + switch attrType { + case attrXORMappedAddress: + addr, err := parseXORMappedAddress(attrValue, txID) + if err == nil { + resp.xorMappedAddr = addr + } + case attrMappedAddress: + addr, err := parseMappedAddress(attrValue) + if err == nil { + resp.mappedAddr = addr + } + case attrOtherAddress: + addr, err := parseMappedAddress(attrValue) + if err == nil { + resp.otherAddr = addr + } + } + + offset += 4 + attrLen + paddingLen(attrLen) + } + + return resp, nil +} + +func parseErrorResponse(data []byte) error { + offset := 0 + for offset+4 <= len(data) { + attrType := binary.BigEndian.Uint16(data[offset : offset+2]) + attrLen := int(binary.BigEndian.Uint16(data[offset+2 : offset+4])) + if offset+4+attrLen > len(data) { + break + } + if attrType == attrErrorCode && attrLen >= 4 { + attrValue := data[offset+4 : offset+4+attrLen] + class := int(attrValue[2] & 0x07) + number := int(attrValue[3]) + code := class*100 + number + if attrLen > 4 { + return E.New("STUN error ", code, ": ", string(attrValue[4:])) + } + return E.New("STUN error ", code) + } + offset += 4 + attrLen + paddingLen(attrLen) + } + return E.New("STUN error response") +} + +func parseXORMappedAddress(data []byte, txID TransactionID) (netip.AddrPort, error) { + if len(data) < 4 { + return netip.AddrPort{}, E.New("XOR-MAPPED-ADDRESS too short") + } + + family := data[1] + xPort := binary.BigEndian.Uint16(data[2:4]) + port := xPort ^ uint16(magicCookie>>16) + + switch family { + case familyIPv4: + if len(data) < 8 { + return netip.AddrPort{}, E.New("XOR-MAPPED-ADDRESS IPv4 too short") + } + var ip [4]byte + binary.BigEndian.PutUint32(ip[:], binary.BigEndian.Uint32(data[4:8])^magicCookie) + return netip.AddrPortFrom(netip.AddrFrom4(ip), port), nil + case familyIPv6: + if len(data) < 20 { + return netip.AddrPort{}, E.New("XOR-MAPPED-ADDRESS IPv6 too short") + } + var ip [16]byte + var xorKey [16]byte + binary.BigEndian.PutUint32(xorKey[0:4], magicCookie) + copy(xorKey[4:16], txID[:]) + for i := range 16 { + ip[i] = data[4+i] ^ xorKey[i] + } + return netip.AddrPortFrom(netip.AddrFrom16(ip), port), nil + default: + return netip.AddrPort{}, E.New("unknown address family: ", family) + } +} + +func parseMappedAddress(data []byte) (netip.AddrPort, error) { + if len(data) < 4 { + return netip.AddrPort{}, E.New("MAPPED-ADDRESS too short") + } + + family := data[1] + port := binary.BigEndian.Uint16(data[2:4]) + + switch family { + case familyIPv4: + if len(data) < 8 { + return netip.AddrPort{}, E.New("MAPPED-ADDRESS IPv4 too short") + } + return netip.AddrPortFrom( + netip.AddrFrom4([4]byte{data[4], data[5], data[6], data[7]}), port, + ), nil + case familyIPv6: + if len(data) < 20 { + return netip.AddrPort{}, E.New("MAPPED-ADDRESS IPv6 too short") + } + var ip [16]byte + copy(ip[:], data[4:20]) + return netip.AddrPortFrom(netip.AddrFrom16(ip), port), nil + default: + return netip.AddrPort{}, E.New("unknown address family: ", family) + } +} + +func roundTrip(conn net.PacketConn, addr net.Addr, txID TransactionID, attrs []stunAttribute, rto time.Duration) (*parsedResponse, time.Duration, error) { + request := buildBindingRequest(txID, attrs...) + currentRTO := rto + retransmitCount := 0 + + sendTime := time.Now() + _, err := conn.WriteTo(request, addr) + if err != nil { + return nil, 0, E.Cause(err, "send STUN request") + } + + buf := make([]byte, 1024) + for { + err = conn.SetReadDeadline(sendTime.Add(currentRTO)) + if err != nil { + return nil, 0, E.Cause(err, "set read deadline") + } + + n, _, readErr := conn.ReadFrom(buf) + if readErr != nil { + if E.IsTimeout(readErr) && retransmitCount < maxRetransmit { + retransmitCount++ + currentRTO *= 2 + sendTime = time.Now() + _, err = conn.WriteTo(request, addr) + if err != nil { + return nil, 0, E.Cause(err, "retransmit STUN request") + } + continue + } + return nil, 0, E.Cause(readErr, "read STUN response") + } + + if n < headerSize || buf[0]&0xC0 != 0 || + binary.BigEndian.Uint32(buf[4:8]) != magicCookie { + continue + } + var receivedTxID TransactionID + copy(receivedTxID[:], buf[8:20]) + if receivedTxID != txID { + continue + } + + latency := time.Since(sendTime) + + resp, parseErr := parseResponse(buf[:n], txID) + if parseErr != nil { + return nil, 0, parseErr + } + + return resp, latency, nil + } +} + +func Run(options Options) (*Result, error) { + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + + server := options.Server + if server == "" { + server = DefaultServer + } + serverSocksaddr := M.ParseSocksaddr(server) + if serverSocksaddr.Port == 0 { + serverSocksaddr.Port = 3478 + } + + reportProgress := options.OnProgress + if reportProgress == nil { + reportProgress = func(Progress) {} + } + + var ( + packetConn net.PacketConn + serverAddr net.Addr + err error + ) + + if options.Dialer != nil { + packetConn, err = options.Dialer.ListenPacket(ctx, serverSocksaddr) + if err != nil { + return nil, E.Cause(err, "create UDP socket") + } + serverAddr = serverSocksaddr + } else { + serverUDPAddr, resolveErr := net.ResolveUDPAddr("udp", serverSocksaddr.String()) + if resolveErr != nil { + return nil, E.Cause(resolveErr, "resolve STUN server") + } + packetConn, err = net.ListenPacket("udp", "") + if err != nil { + return nil, E.Cause(err, "create UDP socket") + } + serverAddr = serverUDPAddr + } + defer func() { + _ = packetConn.Close() + }() + if deadline.NeedAdditionalReadDeadline(packetConn) { + packetConn = deadline.NewPacketConn(bufio.NewPacketConn(packetConn)) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + rto := defaultRTO + + // Phase 1: Binding + reportProgress(Progress{Phase: PhaseBinding}) + + txID := newTransactionID() + resp, latency, err := roundTrip(packetConn, serverAddr, txID, nil, rto) + if err != nil { + return nil, E.Cause(err, "binding request") + } + + rto = max(minRTO, 3*latency) + + externalAddr, ok := resp.externalAddr() + if !ok { + return nil, E.New("no mapped address in response") + } + + result := &Result{ + ExternalAddr: externalAddr.String(), + LatencyMs: int32(latency.Milliseconds()), + } + + reportProgress(Progress{ + Phase: PhaseBinding, + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + }) + + otherAddr := resp.otherAddr + if !otherAddr.IsValid() { + result.NATTypeSupported = false + reportProgress(Progress{ + Phase: PhaseDone, + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + }) + return result, nil + } + result.NATTypeSupported = true + + select { + case <-ctx.Done(): + return result, nil + default: + } + + // Phase 2: NAT Mapping Detection (RFC 5780 Section 4.3) + reportProgress(Progress{ + Phase: PhaseNATMapping, + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + }) + + result.NATMapping = detectNATMapping( + packetConn, serverSocksaddr.Port, externalAddr, otherAddr, rto, + ) + + reportProgress(Progress{ + Phase: PhaseNATMapping, + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NATMapping: result.NATMapping, + }) + + select { + case <-ctx.Done(): + return result, nil + default: + } + + // Phase 3: NAT Filtering Detection (RFC 5780 Section 4.4) + reportProgress(Progress{ + Phase: PhaseNATFiltering, + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NATMapping: result.NATMapping, + }) + + result.NATFiltering = detectNATFiltering(packetConn, serverAddr, rto) + + reportProgress(Progress{ + Phase: PhaseDone, + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NATMapping: result.NATMapping, + NATFiltering: result.NATFiltering, + }) + + return result, nil +} + +func detectNATMapping( + conn net.PacketConn, + serverPort uint16, + externalAddr netip.AddrPort, + otherAddr netip.AddrPort, + rto time.Duration, +) NATMapping { + // Mapping Test II: Send to other_ip:server_port + testIIAddr := net.UDPAddrFromAddrPort( + netip.AddrPortFrom(otherAddr.Addr(), serverPort), + ) + txID2 := newTransactionID() + resp2, _, err := roundTrip(conn, testIIAddr, txID2, nil, rto) + if err != nil { + return NATMappingUnknown + } + + externalAddr2, ok := resp2.externalAddr() + if !ok { + return NATMappingUnknown + } + + if externalAddr == externalAddr2 { + return NATMappingEndpointIndependent + } + + // Mapping Test III: Send to other_ip:other_port + testIIIAddr := net.UDPAddrFromAddrPort(otherAddr) + txID3 := newTransactionID() + resp3, _, err := roundTrip(conn, testIIIAddr, txID3, nil, rto) + if err != nil { + return NATMappingUnknown + } + + externalAddr3, ok := resp3.externalAddr() + if !ok { + return NATMappingUnknown + } + + if externalAddr2 == externalAddr3 { + return NATMappingAddressDependent + } + return NATMappingAddressAndPortDependent +} + +func detectNATFiltering( + conn net.PacketConn, + serverAddr net.Addr, + rto time.Duration, +) NATFiltering { + // Filtering Test II: Request response from different IP and port + txID := newTransactionID() + _, _, err := roundTrip(conn, serverAddr, txID, + []stunAttribute{changeRequestAttr(changeIP | changePort)}, rto) + if err == nil { + return NATFilteringEndpointIndependent + } + + // Filtering Test III: Request response from different port only + txID = newTransactionID() + _, _, err = roundTrip(conn, serverAddr, txID, + []stunAttribute{changeRequestAttr(changePort)}, rto) + if err == nil { + return NATFilteringAddressDependent + } + + return NATFilteringAddressAndPortDependent +} + +func paddingLen(n int) int { + if n%4 == 0 { + return 0 + } + return 4 - n%4 +} diff --git a/common/taskmonitor/monitor.go b/common/taskmonitor/monitor.go new file mode 100644 index 00000000..a23990fa --- /dev/null +++ b/common/taskmonitor/monitor.go @@ -0,0 +1,31 @@ +package taskmonitor + +import ( + "time" + + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" +) + +type Monitor struct { + logger logger.Logger + timeout time.Duration + timer *time.Timer +} + +func New(logger logger.Logger, timeout time.Duration) *Monitor { + return &Monitor{ + logger: logger, + timeout: timeout, + } +} + +func (m *Monitor) Start(taskName ...any) { + m.timer = time.AfterFunc(m.timeout, func() { + m.logger.Warn(F.ToString(taskName...), " take too much time to finish!") + }) +} + +func (m *Monitor) Finish() { + m.timer.Stop() +} diff --git a/common/tls/acme.go b/common/tls/acme.go new file mode 100644 index 00000000..d576fc6b --- /dev/null +++ b/common/tls/acme.go @@ -0,0 +1,134 @@ +//go:build with_acme + +package tls + +import ( + "context" + "crypto/tls" + "strings" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "github.com/caddyserver/certmagic" + "github.com/libdns/acmedns" + "github.com/libdns/alidns" + "github.com/libdns/cloudflare" + "github.com/mholt/acmez/v3/acme" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type acmeWrapper struct { + ctx context.Context + cfg *certmagic.Config + cache *certmagic.Cache + domain []string +} + +func (w *acmeWrapper) Start() error { + return w.cfg.ManageSync(w.ctx, w.domain) +} + +func (w *acmeWrapper) Close() error { + w.cache.Stop() + return nil +} + +func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.SimpleLifecycle, error) { + var acmeServer string + switch options.Provider { + case "", "letsencrypt": + acmeServer = certmagic.LetsEncryptProductionCA + case "zerossl": + acmeServer = certmagic.ZeroSSLProductionCA + default: + if !strings.HasPrefix(options.Provider, "https://") { + return nil, nil, E.New("unsupported acme provider: " + options.Provider) + } + acmeServer = options.Provider + } + var storage certmagic.Storage + if options.DataDirectory != "" { + storage = &certmagic.FileStorage{ + Path: options.DataDirectory, + } + } else { + storage = certmagic.Default.Storage + } + zapLogger := zap.New(zapcore.NewCore( + zapcore.NewConsoleEncoder(ACMEEncoderConfig()), + &ACMELogWriter{Logger: logger}, + zap.DebugLevel, + )) + config := &certmagic.Config{ + DefaultServerName: options.DefaultServerName, + Storage: storage, + Logger: zapLogger, + } + acmeConfig := certmagic.ACMEIssuer{ + CA: acmeServer, + Email: options.Email, + Agreed: true, + DisableHTTPChallenge: options.DisableHTTPChallenge, + DisableTLSALPNChallenge: options.DisableTLSALPNChallenge, + AltHTTPPort: int(options.AlternativeHTTPPort), + AltTLSALPNPort: int(options.AlternativeTLSPort), + Logger: zapLogger, + } + if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" { + var solver certmagic.DNS01Solver + switch dnsOptions.Provider { + case C.DNSProviderAliDNS: + solver.DNSProvider = &alidns.Provider{ + CredentialInfo: alidns.CredentialInfo{ + AccessKeyID: dnsOptions.AliDNSOptions.AccessKeyID, + AccessKeySecret: dnsOptions.AliDNSOptions.AccessKeySecret, + RegionID: dnsOptions.AliDNSOptions.RegionID, + SecurityToken: dnsOptions.AliDNSOptions.SecurityToken, + }, + } + case C.DNSProviderCloudflare: + solver.DNSProvider = &cloudflare.Provider{ + APIToken: dnsOptions.CloudflareOptions.APIToken, + ZoneToken: dnsOptions.CloudflareOptions.ZoneToken, + } + case C.DNSProviderACMEDNS: + solver.DNSProvider = &acmedns.Provider{ + Username: dnsOptions.ACMEDNSOptions.Username, + Password: dnsOptions.ACMEDNSOptions.Password, + Subdomain: dnsOptions.ACMEDNSOptions.Subdomain, + ServerURL: dnsOptions.ACMEDNSOptions.ServerURL, + } + default: + return nil, nil, E.New("unsupported ACME DNS01 provider type: " + dnsOptions.Provider) + } + acmeConfig.DNS01Solver = &solver + } + if options.ExternalAccount != nil && options.ExternalAccount.KeyID != "" { + acmeConfig.ExternalAccount = (*acme.EAB)(options.ExternalAccount) + } + config.Issuers = []certmagic.Issuer{certmagic.NewACMEIssuer(config, acmeConfig)} + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { + return config, nil + }, + Logger: zapLogger, + }) + config = certmagic.New(cache, *config) + var tlsConfig *tls.Config + if acmeConfig.DisableTLSALPNChallenge || acmeConfig.DNS01Solver != nil { + tlsConfig = &tls.Config{ + GetCertificate: config.GetCertificate, + } + } else { + tlsConfig = &tls.Config{ + GetCertificate: config.GetCertificate, + NextProtos: []string{C.ACMETLS1Protocol}, + } + } + return tlsConfig, &acmeWrapper{ctx: ctx, cfg: config, cache: cache, domain: options.Domain}, nil +} diff --git a/common/tls/acme_logger.go b/common/tls/acme_logger.go new file mode 100644 index 00000000..cb3a1e3c --- /dev/null +++ b/common/tls/acme_logger.go @@ -0,0 +1,41 @@ +package tls + +import ( + "strings" + + "github.com/sagernet/sing/common/logger" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type ACMELogWriter struct { + Logger logger.Logger +} + +func (w *ACMELogWriter) Write(p []byte) (n int, err error) { + logLine := strings.ReplaceAll(string(p), " ", ": ") + switch { + case strings.HasPrefix(logLine, "error: "): + w.Logger.Error(logLine[7:]) + case strings.HasPrefix(logLine, "warn: "): + w.Logger.Warn(logLine[6:]) + case strings.HasPrefix(logLine, "info: "): + w.Logger.Info(logLine[6:]) + case strings.HasPrefix(logLine, "debug: "): + w.Logger.Debug(logLine[7:]) + default: + w.Logger.Debug(logLine) + } + return len(p), nil +} + +func (w *ACMELogWriter) Sync() error { + return nil +} + +func ACMEEncoderConfig() zapcore.EncoderConfig { + config := zap.NewProductionEncoderConfig() + config.TimeKey = zapcore.OmitKey + return config +} diff --git a/common/tls/acme_stub.go b/common/tls/acme_stub.go new file mode 100644 index 00000000..53ced06d --- /dev/null +++ b/common/tls/acme_stub.go @@ -0,0 +1,17 @@ +//go:build !with_acme + +package tls + +import ( + "context" + "crypto/tls" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.SimpleLifecycle, error) { + return nil, nil, E.New(`ACME is not included in this build, rebuild with -tags with_acme`) +} diff --git a/common/tls/client.go b/common/tls/client.go new file mode 100644 index 00000000..83969954 --- /dev/null +++ b/common/tls/client.go @@ -0,0 +1,139 @@ +package tls + +import ( + "context" + "crypto/tls" + "errors" + "net" + "os" + + "github.com/sagernet/sing-box/common/badtls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" +) + +func NewDialerFromOptions(ctx context.Context, logger logger.ContextLogger, dialer N.Dialer, serverAddress string, options option.OutboundTLSOptions) (N.Dialer, error) { + if !options.Enabled { + return dialer, nil + } + config, err := NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger, + ServerAddress: serverAddress, + Options: options, + }) + if err != nil { + return nil, err + } + return NewDialer(dialer, config), nil +} + +func NewClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + return NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger, + ServerAddress: serverAddress, + Options: options, + }) +} + +type ClientOptions struct { + Context context.Context + Logger logger.ContextLogger + ServerAddress string + Options option.OutboundTLSOptions + KTLSCompatible bool +} + +func NewClientWithOptions(options ClientOptions) (Config, error) { + if !options.Options.Enabled { + return nil, nil + } + if !options.KTLSCompatible { + if options.Options.KernelTx { + options.Logger.Warn("enabling kTLS TX in current scenarios will definitely reduce performance, please checkout https://sing-box.sagernet.org/configuration/shared/tls/#kernel_tx") + } + } + if options.Options.KernelRx { + options.Logger.Warn("enabling kTLS RX will definitely reduce performance, please checkout https://sing-box.sagernet.org/configuration/shared/tls/#kernel_rx") + } + if options.Options.Reality != nil && options.Options.Reality.Enabled { + return NewRealityClient(options.Context, options.Logger, options.ServerAddress, options.Options) + } else if options.Options.UTLS != nil && options.Options.UTLS.Enabled { + return NewUTLSClient(options.Context, options.Logger, options.ServerAddress, options.Options) + } + return NewSTDClient(options.Context, options.Logger, options.ServerAddress, options.Options) +} + +func ClientHandshake(ctx context.Context, conn net.Conn, config Config) (Conn, error) { + ctx, cancel := context.WithTimeout(ctx, C.TCPTimeout) + defer cancel() + tlsConn, err := aTLS.ClientHandshake(ctx, conn, config) + if err != nil { + return nil, err + } + readWaitConn, err := badtls.NewReadWaitConn(tlsConn) + if err == nil { + return readWaitConn, nil + } else if err != os.ErrInvalid { + return nil, err + } + return tlsConn, nil +} + +type Dialer interface { + N.Dialer + DialTLSContext(ctx context.Context, destination M.Socksaddr) (Conn, error) +} + +type defaultDialer struct { + dialer N.Dialer + config Config +} + +func NewDialer(dialer N.Dialer, config Config) Dialer { + return &defaultDialer{dialer, config} +} + +func (d *defaultDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) != N.NetworkTCP { + return nil, os.ErrInvalid + } + return d.DialTLSContext(ctx, destination) +} + +func (d *defaultDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +func (d *defaultDialer) DialTLSContext(ctx context.Context, destination M.Socksaddr) (Conn, error) { + return d.dialContext(ctx, destination, true) +} + +func (d *defaultDialer) dialContext(ctx context.Context, destination M.Socksaddr, echRetry bool) (Conn, error) { + conn, err := d.dialer.DialContext(ctx, N.NetworkTCP, destination) + if err != nil { + return nil, err + } + tlsConn, err := aTLS.ClientHandshake(ctx, conn, d.config) + if err != nil { + conn.Close() + var echErr *tls.ECHRejectionError + if echRetry && errors.As(err, &echErr) && len(echErr.RetryConfigList) > 0 { + if echConfig, isECH := d.config.(ECHCapableConfig); isECH { + echConfig.SetECHConfigList(echErr.RetryConfigList) + return d.dialContext(ctx, destination, false) + } + } + return nil, err + } + return tlsConn, nil +} + +func (d *defaultDialer) Upstream() any { + return d.dialer +} diff --git a/common/tls/common.go b/common/tls/common.go new file mode 100644 index 00000000..66cf1223 --- /dev/null +++ b/common/tls/common.go @@ -0,0 +1,12 @@ +package tls + +const ( + VersionTLS10 = 0x0301 + VersionTLS11 = 0x0302 + VersionTLS12 = 0x0303 + VersionTLS13 = 0x0304 + + // Deprecated: SSLv3 is cryptographically broken, and is no longer + // supported by this package. See golang.org/issue/32716. + VersionSSL30 = 0x0300 +) diff --git a/common/tls/config.go b/common/tls/config.go new file mode 100644 index 00000000..72bbd194 --- /dev/null +++ b/common/tls/config.go @@ -0,0 +1,37 @@ +package tls + +import ( + "crypto/tls" + + E "github.com/sagernet/sing/common/exceptions" + aTLS "github.com/sagernet/sing/common/tls" +) + +type ( + Config = aTLS.Config + ConfigCompat = aTLS.ConfigCompat + ServerConfig = aTLS.ServerConfig + ServerConfigCompat = aTLS.ServerConfigCompat + WithSessionIDGenerator = aTLS.WithSessionIDGenerator + Conn = aTLS.Conn + + STDConfig = tls.Config + STDConn = tls.Conn + ConnectionState = tls.ConnectionState + CurveID = tls.CurveID +) + +func ParseTLSVersion(version string) (uint16, error) { + switch version { + case "1.0": + return tls.VersionTLS10, nil + case "1.1": + return tls.VersionTLS11, nil + case "1.2": + return tls.VersionTLS12, nil + case "1.3": + return tls.VersionTLS13, nil + default: + return 0, E.New("unknown tls version:", version) + } +} diff --git a/common/tls/ech.go b/common/tls/ech.go new file mode 100644 index 00000000..8c884cab --- /dev/null +++ b/common/tls/ech.go @@ -0,0 +1,208 @@ +//go:build go1.24 + +package tls + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/pem" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "golang.org/x/crypto/cryptobyte" +) + +func parseECHClientConfig(ctx context.Context, clientConfig ECHCapableConfig, options option.OutboundTLSOptions) (Config, error) { + var echConfig []byte + if len(options.ECH.Config) > 0 { + echConfig = []byte(strings.Join(options.ECH.Config, "\n")) + } else if options.ECH.ConfigPath != "" { + content, err := os.ReadFile(options.ECH.ConfigPath) + if err != nil { + return nil, E.Cause(err, "read ECH config") + } + echConfig = content + } + //nolint:staticcheck + if options.ECH.PQSignatureSchemesEnabled || options.ECH.DynamicRecordSizingDisabled { + return nil, E.New("legacy ECH options are deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0") + } + if len(echConfig) > 0 { + block, rest := pem.Decode(echConfig) + if block == nil || block.Type != "ECH CONFIGS" || len(rest) > 0 { + return nil, E.New("invalid ECH configs pem") + } + clientConfig.SetECHConfigList(block.Bytes) + return clientConfig, nil + } else { + return &ECHClientConfig{ + ECHCapableConfig: clientConfig, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + queryServerName: options.ECH.QueryServerName, + }, nil + } +} + +func parseECHServerConfig(ctx context.Context, options option.InboundTLSOptions, tlsConfig *tls.Config, echKeyPath *string) error { + var echKey []byte + if len(options.ECH.Key) > 0 { + echKey = []byte(strings.Join(options.ECH.Key, "\n")) + } else if options.ECH.KeyPath != "" { + content, err := os.ReadFile(options.ECH.KeyPath) + if err != nil { + return E.Cause(err, "read ECH keys") + } + echKey = content + *echKeyPath = options.ECH.KeyPath + } else { + return E.New("missing ECH keys") + } + echKeys, err := parseECHKeys(echKey) + if err != nil { + return E.Cause(err, "parse ECH keys") + } + tlsConfig.EncryptedClientHelloKeys = echKeys + //nolint:staticcheck + if options.ECH.PQSignatureSchemesEnabled || options.ECH.DynamicRecordSizingDisabled { + return E.New("legacy ECH options are deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0") + } + return nil +} + +func (c *STDServerConfig) setECHServerConfig(echKey []byte) error { + echKeys, err := parseECHKeys(echKey) + if err != nil { + return err + } + c.access.Lock() + config := c.config.Clone() + config.EncryptedClientHelloKeys = echKeys + c.config = config + c.access.Unlock() + return nil +} + +func parseECHKeys(echKey []byte) ([]tls.EncryptedClientHelloKey, error) { + block, _ := pem.Decode(echKey) + if block == nil || block.Type != "ECH KEYS" { + return nil, E.New("invalid ECH keys pem") + } + echKeys, err := UnmarshalECHKeys(block.Bytes) + if err != nil { + return nil, E.Cause(err, "parse ECH keys") + } + return echKeys, nil +} + +type ECHClientConfig struct { + ECHCapableConfig + access sync.Mutex + dnsRouter adapter.DNSRouter + queryServerName string + lastTTL time.Duration + lastUpdate time.Time +} + +func (s *ECHClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (aTLS.Conn, error) { + tlsConn, err := s.fetchAndHandshake(ctx, conn) + if err != nil { + return nil, err + } + err = tlsConn.HandshakeContext(ctx) + if err != nil { + return nil, err + } + return tlsConn, nil +} + +func (s *ECHClientConfig) fetchAndHandshake(ctx context.Context, conn net.Conn) (aTLS.Conn, error) { + s.access.Lock() + defer s.access.Unlock() + if len(s.ECHConfigList()) == 0 || s.lastTTL == 0 || time.Since(s.lastUpdate) > s.lastTTL { + queryServerName := s.queryServerName + if queryServerName == "" { + queryServerName = s.ServerName() + } + message := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{ + { + Name: mDNS.Fqdn(queryServerName), + Qtype: mDNS.TypeHTTPS, + Qclass: mDNS.ClassINET, + }, + }, + } + response, err := s.dnsRouter.Exchange(ctx, message, adapter.DNSQueryOptions{}) + if err != nil { + return nil, E.Cause(err, "fetch ECH config list") + } + if response.Rcode != mDNS.RcodeSuccess { + return nil, E.Cause(dns.RcodeError(response.Rcode), "fetch ECH config list") + } + match: + for _, rr := range response.Answer { + switch resource := rr.(type) { + case *mDNS.HTTPS: + for _, value := range resource.Value { + if value.Key().String() == "ech" { + echConfigList, err := base64.StdEncoding.DecodeString(value.String()) + if err != nil { + return nil, E.Cause(err, "decode ECH config") + } + s.lastTTL = time.Duration(rr.Header().Ttl) * time.Second + s.lastUpdate = time.Now() + s.SetECHConfigList(echConfigList) + break match + } + } + } + } + if len(s.ECHConfigList()) == 0 { + return nil, E.New("no ECH config found in DNS records") + } + } + return s.Client(conn) +} + +func (s *ECHClientConfig) Clone() Config { + return &ECHClientConfig{ + ECHCapableConfig: s.ECHCapableConfig.Clone().(ECHCapableConfig), + dnsRouter: s.dnsRouter, + queryServerName: s.queryServerName, + lastUpdate: s.lastUpdate, + } +} + +func UnmarshalECHKeys(raw []byte) ([]tls.EncryptedClientHelloKey, error) { + var keys []tls.EncryptedClientHelloKey + rawString := cryptobyte.String(raw) + for !rawString.Empty() { + var key tls.EncryptedClientHelloKey + if !rawString.ReadUint16LengthPrefixed((*cryptobyte.String)(&key.PrivateKey)) { + return nil, E.New("error parsing private key") + } + if !rawString.ReadUint16LengthPrefixed((*cryptobyte.String)(&key.Config)) { + return nil, E.New("error parsing config") + } + keys = append(keys, key) + } + if len(keys) == 0 { + return nil, E.New("empty ECH keys") + } + return keys, nil +} diff --git a/common/tls/ech_shared.go b/common/tls/ech_shared.go new file mode 100644 index 00000000..1cdfb7e1 --- /dev/null +++ b/common/tls/ech_shared.go @@ -0,0 +1,81 @@ +package tls + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/pem" + + "golang.org/x/crypto/cryptobyte" +) + +type ECHCapableConfig interface { + Config + ECHConfigList() []byte + SetECHConfigList([]byte) +} + +func ECHKeygenDefault(publicName string) (configPem string, keyPem string, err error) { + echKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return + } + echConfig, err := marshalECHConfig(0, echKey.PublicKey().Bytes(), publicName, 0) + if err != nil { + return + } + configBuilder := cryptobyte.NewBuilder(nil) + configBuilder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(echConfig) + }) + configBytes, err := configBuilder.Bytes() + if err != nil { + return + } + keyBuilder := cryptobyte.NewBuilder(nil) + keyBuilder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(echKey.Bytes()) + }) + keyBuilder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(echConfig) + }) + keyBytes, err := keyBuilder.Bytes() + if err != nil { + return + } + configPem = string(pem.EncodeToMemory(&pem.Block{Type: "ECH CONFIGS", Bytes: configBytes})) + keyPem = string(pem.EncodeToMemory(&pem.Block{Type: "ECH KEYS", Bytes: keyBytes})) + return +} + +func marshalECHConfig(id uint8, pubKey []byte, publicName string, maxNameLen uint8) ([]byte, error) { + const extensionEncryptedClientHello = 0xfe0d + const DHKEM_X25519_HKDF_SHA256 = 0x0020 + const KDF_HKDF_SHA256 = 0x0001 + builder := cryptobyte.NewBuilder(nil) + builder.AddUint16(extensionEncryptedClientHello) + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddUint8(id) + + builder.AddUint16(DHKEM_X25519_HKDF_SHA256) // The only DHKEM we support + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(pubKey) + }) + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + const ( + AEAD_AES_128_GCM = 0x0001 + AEAD_AES_256_GCM = 0x0002 + AEAD_ChaCha20Poly1305 = 0x0003 + ) + for _, aeadID := range []uint16{AEAD_AES_128_GCM, AEAD_AES_256_GCM, AEAD_ChaCha20Poly1305} { + builder.AddUint16(KDF_HKDF_SHA256) // The only KDF we support + builder.AddUint16(aeadID) + } + }) + builder.AddUint8(maxNameLen) + builder.AddUint8LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes([]byte(publicName)) + }) + builder.AddUint16(0) // extensions + }) + return builder.Bytes() +} diff --git a/common/tls/ech_tag_stub.go b/common/tls/ech_tag_stub.go new file mode 100644 index 00000000..1a9cbd56 --- /dev/null +++ b/common/tls/ech_tag_stub.go @@ -0,0 +1,5 @@ +//go:build with_ech + +package tls + +var _ int = "Due to the migration to stdlib, the separate `with_ech` build tag has been deprecated and is no longer needed, please update your build configuration." diff --git a/common/tls/ktls.go b/common/tls/ktls.go new file mode 100644 index 00000000..dd564f53 --- /dev/null +++ b/common/tls/ktls.go @@ -0,0 +1,67 @@ +package tls + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/common/ktls" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + aTLS "github.com/sagernet/sing/common/tls" +) + +type KTLSClientConfig struct { + Config + logger logger.ContextLogger + kernelTx, kernelRx bool +} + +func (w *KTLSClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (aTLS.Conn, error) { + tlsConn, err := aTLS.ClientHandshake(ctx, conn, w.Config) + if err != nil { + return nil, err + } + kConn, err := ktls.NewConn(ctx, w.logger, tlsConn, w.kernelTx, w.kernelRx) + if err != nil { + tlsConn.Close() + return nil, E.Cause(err, "initialize kernel TLS") + } + return kConn, nil +} + +func (w *KTLSClientConfig) Clone() Config { + return &KTLSClientConfig{ + w.Config.Clone(), + w.logger, + w.kernelTx, + w.kernelRx, + } +} + +type KTlSServerConfig struct { + ServerConfig + logger logger.ContextLogger + kernelTx, kernelRx bool +} + +func (w *KTlSServerConfig) ServerHandshake(ctx context.Context, conn net.Conn) (aTLS.Conn, error) { + tlsConn, err := aTLS.ServerHandshake(ctx, conn, w.ServerConfig) + if err != nil { + return nil, err + } + kConn, err := ktls.NewConn(ctx, w.logger, tlsConn, w.kernelTx, w.kernelRx) + if err != nil { + tlsConn.Close() + return nil, E.Cause(err, "initialize kernel TLS") + } + return kConn, nil +} + +func (w *KTlSServerConfig) Clone() Config { + return &KTlSServerConfig{ + w.ServerConfig.Clone().(ServerConfig), + w.logger, + w.kernelTx, + w.kernelRx, + } +} diff --git a/common/tls/mkcert.go b/common/tls/mkcert.go new file mode 100644 index 00000000..4e0ed102 --- /dev/null +++ b/common/tls/mkcert.go @@ -0,0 +1,65 @@ +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" +) + +func GenerateKeyPair(parent *x509.Certificate, parentKey any, timeFunc func() time.Time, serverName string) (*tls.Certificate, error) { + if timeFunc == nil { + timeFunc = time.Now + } + privateKeyPem, publicKeyPem, err := GenerateCertificate(parent, parentKey, timeFunc, serverName, timeFunc().Add(time.Hour)) + if err != nil { + return nil, err + } + certificate, err := tls.X509KeyPair(publicKeyPem, privateKeyPem) + if err != nil { + return nil, err + } + return &certificate, err +} + +func GenerateCertificate(parent *x509.Certificate, parentKey any, timeFunc func() time.Time, serverName string, expire time.Time) (privateKeyPem []byte, publicKeyPem []byte, err error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return + } + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return + } + template := &x509.Certificate{ + SerialNumber: serialNumber, + NotBefore: timeFunc().Add(time.Hour * -1), + NotAfter: expire, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + Subject: pkix.Name{ + CommonName: serverName, + }, + DNSNames: []string{serverName}, + } + if parent == nil { + parent = template + parentKey = key + } + publicDer, err := x509.CreateCertificate(rand.Reader, template, parent, key.Public(), parentKey) + if err != nil { + return + } + privateDer, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return + } + publicKeyPem = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: publicDer}) + privateKeyPem = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDer}) + return +} diff --git a/common/tls/reality_client.go b/common/tls/reality_client.go new file mode 100644 index 00000000..9362d2f8 --- /dev/null +++ b/common/tls/reality_client.go @@ -0,0 +1,332 @@ +//go:build with_utls + +package tls + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + mRand "math/rand" + "net" + "net/http" + "reflect" + "strings" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/debug" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/ntp" + aTLS "github.com/sagernet/sing/common/tls" + + utls "github.com/metacubex/utls" + "golang.org/x/crypto/hkdf" + "golang.org/x/net/http2" +) + +var _ ConfigCompat = (*RealityClientConfig)(nil) + +type RealityClientConfig struct { + ctx context.Context + uClient *UTLSClientConfig + publicKey []byte + shortID [8]byte +} + +func NewRealityClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + if options.UTLS == nil || !options.UTLS.Enabled { + return nil, E.New("uTLS is required by reality client") + } + + uClient, err := NewUTLSClient(ctx, logger, serverAddress, options) + if err != nil { + return nil, err + } + + publicKey, err := base64.RawURLEncoding.DecodeString(options.Reality.PublicKey) + if err != nil { + return nil, E.Cause(err, "decode public_key") + } + if len(publicKey) != 32 { + return nil, E.New("invalid public_key") + } + var shortID [8]byte + decodedLen, err := hex.Decode(shortID[:], []byte(options.Reality.ShortID)) + if err != nil { + return nil, E.Cause(err, "decode short_id") + } + if decodedLen > 8 { + return nil, E.New("invalid short_id") + } + + var config Config = &RealityClientConfig{ctx, uClient.(*UTLSClientConfig), publicKey, shortID} + if options.KernelRx || options.KernelTx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTLSClientConfig{ + Config: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} + +func (e *RealityClientConfig) ServerName() string { + return e.uClient.ServerName() +} + +func (e *RealityClientConfig) SetServerName(serverName string) { + e.uClient.SetServerName(serverName) +} + +func (e *RealityClientConfig) NextProtos() []string { + return e.uClient.NextProtos() +} + +func (e *RealityClientConfig) SetNextProtos(nextProto []string) { + e.uClient.SetNextProtos(nextProto) +} + +func (e *RealityClientConfig) STDConfig() (*STDConfig, error) { + return nil, E.New("unsupported usage for reality") +} + +func (e *RealityClientConfig) Client(conn net.Conn) (Conn, error) { + return ClientHandshake(context.Background(), conn, e) +} + +func (e *RealityClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (aTLS.Conn, error) { + verifier := &realityVerifier{ + serverName: e.uClient.ServerName(), + } + uConfig := e.uClient.config.Clone() + uConfig.InsecureSkipVerify = true + uConfig.SessionTicketsDisabled = true + uConfig.VerifyPeerCertificate = verifier.VerifyPeerCertificate + uConn := utls.UClient(conn, uConfig, e.uClient.id) + verifier.UConn = uConn + err := uConn.BuildHandshakeState() + if err != nil { + return nil, err + } + for _, extension := range uConn.Extensions { + if ce, ok := extension.(*utls.SupportedCurvesExtension); ok { + ce.Curves = common.Filter(ce.Curves, func(curveID utls.CurveID) bool { + return curveID != utls.X25519MLKEM768 + }) + } + if ks, ok := extension.(*utls.KeyShareExtension); ok { + ks.KeyShares = common.Filter(ks.KeyShares, func(share utls.KeyShare) bool { + return share.Group != utls.X25519MLKEM768 + }) + } + } + err = uConn.BuildHandshakeState() + if err != nil { + return nil, err + } + + if len(uConfig.NextProtos) > 0 { + for _, extension := range uConn.Extensions { + if alpnExtension, isALPN := extension.(*utls.ALPNExtension); isALPN { + alpnExtension.AlpnProtocols = uConfig.NextProtos + break + } + } + } + + hello := uConn.HandshakeState.Hello + hello.SessionId = make([]byte, 32) + copy(hello.Raw[39:], hello.SessionId) + + var nowTime time.Time + if uConfig.Time != nil { + nowTime = uConfig.Time() + } else { + nowTime = time.Now() + } + binary.BigEndian.PutUint64(hello.SessionId, uint64(nowTime.Unix())) + + hello.SessionId[0] = 1 + hello.SessionId[1] = 8 + hello.SessionId[2] = 1 + binary.BigEndian.PutUint32(hello.SessionId[4:], uint32(time.Now().Unix())) + copy(hello.SessionId[8:], e.shortID[:]) + if debug.Enabled { + fmt.Printf("REALITY hello.sessionId[:16]: %v\n", hello.SessionId[:16]) + } + publicKey, err := ecdh.X25519().NewPublicKey(e.publicKey) + if err != nil { + return nil, err + } + keyShareKeys := uConn.HandshakeState.State13.KeyShareKeys + if keyShareKeys == nil { + return nil, E.New("nil KeyShareKeys") + } + ecdheKey := keyShareKeys.Ecdhe + if ecdheKey == nil { + return nil, E.New("nil ecdheKey") + } + authKey, err := ecdheKey.ECDH(publicKey) + if err != nil { + return nil, err + } + if authKey == nil { + return nil, E.New("nil auth_key") + } + verifier.authKey = authKey + _, err = hkdf.New(sha256.New, authKey, hello.Random[:20], []byte("REALITY")).Read(authKey) + if err != nil { + return nil, err + } + aesBlock, _ := aes.NewCipher(authKey) + aesGcmCipher, _ := cipher.NewGCM(aesBlock) + aesGcmCipher.Seal(hello.SessionId[:0], hello.Random[20:], hello.SessionId[:16], hello.Raw) + copy(hello.Raw[39:], hello.SessionId) + if debug.Enabled { + fmt.Printf("REALITY hello.sessionId: %v\n", hello.SessionId) + fmt.Printf("REALITY uConn.AuthKey: %v\n", authKey) + } + + err = uConn.HandshakeContext(ctx) + if err != nil { + return nil, err + } + + if debug.Enabled { + fmt.Printf("REALITY Conn.Verified: %v\n", verifier.verified) + } + + if !verifier.verified { + go realityClientFallback(e.ctx, uConn, e.uClient.ServerName(), e.uClient.id) + return nil, E.New("reality verification failed") + } + + return &realityClientConnWrapper{uConn}, nil +} + +func realityClientFallback(ctx context.Context, uConn net.Conn, serverName string, fingerprint utls.ClientHelloID) { + defer uConn.Close() + client := &http.Client{ + Transport: &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string, config *tls.Config) (net.Conn, error) { + return uConn, nil + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + }, + } + request, _ := http.NewRequest("GET", "https://"+serverName, nil) + request.Header.Set("User-Agent", fingerprint.Client) + request.AddCookie(&http.Cookie{Name: "padding", Value: strings.Repeat("0", mRand.Intn(32)+30)}) + response, err := client.Do(request) + if err != nil { + return + } + _, _ = io.Copy(io.Discard, response.Body) + response.Body.Close() +} + +func (e *RealityClientConfig) Clone() Config { + return &RealityClientConfig{ + e.ctx, + e.uClient.Clone().(*UTLSClientConfig), + e.publicKey, + e.shortID, + } +} + +type realityVerifier struct { + *utls.UConn + serverName string + authKey []byte + verified bool +} + +func (c *realityVerifier) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + p, _ := reflect.TypeOf(c.Conn).Elem().FieldByName("peerCertificates") + certs := *(*([]*x509.Certificate))(unsafe.Pointer(uintptr(unsafe.Pointer(c.Conn)) + p.Offset)) + if pub, ok := certs[0].PublicKey.(ed25519.PublicKey); ok { + h := hmac.New(sha512.New, c.authKey) + h.Write(pub) + if bytes.Equal(h.Sum(nil), certs[0].Signature) { + c.verified = true + return nil + } + } + opts := x509.VerifyOptions{ + DNSName: c.serverName, + Intermediates: x509.NewCertPool(), + } + for _, cert := range certs[1:] { + opts.Intermediates.AddCert(cert) + } + if _, err := certs[0].Verify(opts); err != nil { + return err + } + return nil +} + +type realityClientConnWrapper struct { + *utls.UConn +} + +func (c *realityClientConnWrapper) ConnectionState() tls.ConnectionState { + state := c.Conn.ConnectionState() + //nolint:staticcheck + return tls.ConnectionState{ + Version: state.Version, + HandshakeComplete: state.HandshakeComplete, + DidResume: state.DidResume, + CipherSuite: state.CipherSuite, + NegotiatedProtocol: state.NegotiatedProtocol, + NegotiatedProtocolIsMutual: state.NegotiatedProtocolIsMutual, + ServerName: state.ServerName, + PeerCertificates: state.PeerCertificates, + VerifiedChains: state.VerifiedChains, + SignedCertificateTimestamps: state.SignedCertificateTimestamps, + OCSPResponse: state.OCSPResponse, + TLSUnique: state.TLSUnique, + } +} + +func (c *realityClientConnWrapper) Upstream() any { + return c.UConn +} + +// Due to low implementation quality, the reality server intercepted half close and caused memory leaks. +// We fixed it by calling Close() directly. +func (c *realityClientConnWrapper) CloseWrite() error { + return c.Close() +} + +func (c *realityClientConnWrapper) ReaderReplaceable() bool { + return true +} + +func (c *realityClientConnWrapper) WriterReplaceable() bool { + return true +} diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go new file mode 100644 index 00000000..c2e70733 --- /dev/null +++ b/common/tls/reality_server.go @@ -0,0 +1,239 @@ +//go:build with_utls + +package tls + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "fmt" + "net" + "time" + + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + + utls "github.com/metacubex/utls" +) + +var _ ServerConfigCompat = (*RealityServerConfig)(nil) + +type RealityServerConfig struct { + config *utls.RealityConfig +} + +func NewRealityServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { + var tlsConfig utls.RealityConfig + + if options.CertificateProvider != nil { + return nil, E.New("certificate_provider is unavailable in reality") + } + //nolint:staticcheck + if options.ACME != nil && len(options.ACME.Domain) > 0 { + return nil, E.New("acme is unavailable in reality") + } + tlsConfig.Time = ntp.TimeFuncFromContext(ctx) + if options.ServerName != "" { + tlsConfig.ServerName = options.ServerName + } + if len(options.ALPN) > 0 { + tlsConfig.NextProtos = append(tlsConfig.NextProtos, options.ALPN...) + } + if options.MinVersion != "" { + minVersion, err := ParseTLSVersion(options.MinVersion) + if err != nil { + return nil, E.Cause(err, "parse min_version") + } + tlsConfig.MinVersion = minVersion + } + if options.MaxVersion != "" { + maxVersion, err := ParseTLSVersion(options.MaxVersion) + if err != nil { + return nil, E.Cause(err, "parse max_version") + } + tlsConfig.MaxVersion = maxVersion + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + if len(options.CurvePreferences) > 0 { + return nil, E.New("curve preferences is unavailable in reality") + } + if len(options.Certificate) > 0 || options.CertificatePath != "" || len(options.ClientCertificatePublicKeySHA256) > 0 { + return nil, E.New("certificate is unavailable in reality") + } + if len(options.Key) > 0 || options.KeyPath != "" { + return nil, E.New("key is unavailable in reality") + } + + tlsConfig.SessionTicketsDisabled = true + tlsConfig.Log = func(format string, v ...any) { + if logger != nil { + logger.Trace(fmt.Sprintf(format, v...)) + } + } + tlsConfig.Type = N.NetworkTCP + tlsConfig.Dest = options.Reality.Handshake.ServerOptions.Build().String() + + tlsConfig.ServerNames = map[string]bool{options.ServerName: true} + privateKey, err := base64.RawURLEncoding.DecodeString(options.Reality.PrivateKey) + if err != nil { + return nil, E.Cause(err, "decode private key") + } + if len(privateKey) != 32 { + return nil, E.New("invalid private key") + } + tlsConfig.PrivateKey = privateKey + tlsConfig.MaxTimeDiff = time.Duration(options.Reality.MaxTimeDifference) + + tlsConfig.ShortIds = make(map[[8]byte]bool) + if len(options.Reality.ShortID) == 0 { + tlsConfig.ShortIds[[8]byte{0}] = true + } else { + for i, shortIDString := range options.Reality.ShortID { + var shortID [8]byte + decodedLen, err := hex.Decode(shortID[:], []byte(shortIDString)) + if err != nil { + return nil, E.Cause(err, "decode short_id[", i, "]: ", shortIDString) + } + if decodedLen > 8 { + return nil, E.New("invalid short_id[", i, "]: ", shortIDString) + } + tlsConfig.ShortIds[shortID] = true + } + } + + handshakeDialer, err := dialer.New(ctx, options.Reality.Handshake.DialerOptions, options.Reality.Handshake.ServerIsDomain()) + if err != nil { + return nil, err + } + tlsConfig.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return handshakeDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + } + + if options.ECH != nil && options.ECH.Enabled { + return nil, E.New("Reality is conflict with ECH") + } + var config ServerConfig = &RealityServerConfig{&tlsConfig} + if options.KernelTx || options.KernelRx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTlSServerConfig{ + ServerConfig: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} + +func (c *RealityServerConfig) ServerName() string { + return c.config.ServerName +} + +func (c *RealityServerConfig) SetServerName(serverName string) { + c.config.ServerName = serverName +} + +func (c *RealityServerConfig) NextProtos() []string { + return c.config.NextProtos +} + +func (c *RealityServerConfig) SetNextProtos(nextProto []string) { + c.config.NextProtos = nextProto +} + +func (c *RealityServerConfig) STDConfig() (*tls.Config, error) { + return nil, E.New("unsupported usage for reality") +} + +func (c *RealityServerConfig) Client(conn net.Conn) (Conn, error) { + return ClientHandshake(context.Background(), conn, c) +} + +func (c *RealityServerConfig) Start() error { + return nil +} + +func (c *RealityServerConfig) Close() error { + return nil +} + +func (c *RealityServerConfig) Server(conn net.Conn) (Conn, error) { + return ServerHandshake(context.Background(), conn, c) +} + +func (c *RealityServerConfig) ServerHandshake(ctx context.Context, conn net.Conn) (Conn, error) { + tlsConn, err := utls.RealityServer(ctx, conn, c.config) + if err != nil { + return nil, err + } + return &realityConnWrapper{Conn: tlsConn}, nil +} + +func (c *RealityServerConfig) Clone() Config { + return &RealityServerConfig{ + config: c.config.Clone(), + } +} + +var _ Conn = (*realityConnWrapper)(nil) + +type realityConnWrapper struct { + *utls.Conn +} + +func (c *realityConnWrapper) ConnectionState() ConnectionState { + state := c.Conn.ConnectionState() + //nolint:staticcheck + return tls.ConnectionState{ + Version: state.Version, + HandshakeComplete: state.HandshakeComplete, + DidResume: state.DidResume, + CipherSuite: state.CipherSuite, + NegotiatedProtocol: state.NegotiatedProtocol, + NegotiatedProtocolIsMutual: state.NegotiatedProtocolIsMutual, + ServerName: state.ServerName, + PeerCertificates: state.PeerCertificates, + VerifiedChains: state.VerifiedChains, + SignedCertificateTimestamps: state.SignedCertificateTimestamps, + OCSPResponse: state.OCSPResponse, + TLSUnique: state.TLSUnique, + } +} + +func (c *realityConnWrapper) Upstream() any { + return c.Conn +} + +// Due to low implementation quality, the reality server intercepted half close and caused memory leaks. +// We fixed it by calling Close() directly. +func (c *realityConnWrapper) CloseWrite() error { + return c.Close() +} + +func (c *realityConnWrapper) ReaderReplaceable() bool { + return true +} + +func (c *realityConnWrapper) WriterReplaceable() bool { + return true +} diff --git a/common/tls/reality_stub.go b/common/tls/reality_stub.go new file mode 100644 index 00000000..0feb2aac --- /dev/null +++ b/common/tls/reality_stub.go @@ -0,0 +1,5 @@ +//go:build with_reality_server + +package tls + +var _ int = "The separate `with_reality_server` build tag has been merged into `with_utls` and is no longer needed, please update your build configuration." diff --git a/common/tls/server.go b/common/tls/server.go new file mode 100644 index 00000000..74b240fc --- /dev/null +++ b/common/tls/server.go @@ -0,0 +1,62 @@ +package tls + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/common/badtls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + aTLS "github.com/sagernet/sing/common/tls" +) + +type ServerOptions struct { + Context context.Context + Logger log.ContextLogger + Options option.InboundTLSOptions + KTLSCompatible bool +} + +func NewServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { + return NewServerWithOptions(ServerOptions{ + Context: ctx, + Logger: logger, + Options: options, + }) +} + +func NewServerWithOptions(options ServerOptions) (ServerConfig, error) { + if !options.Options.Enabled { + return nil, nil + } + if !options.KTLSCompatible { + if options.Options.KernelTx { + options.Logger.Warn("enabling kTLS TX in current scenarios will definitely reduce performance, please checkout https://sing-box.sagernet.org/configuration/shared/tls/#kernel_tx") + } + } + if options.Options.KernelRx { + options.Logger.Warn("enabling kTLS RX will definitely reduce performance, please checkout https://sing-box.sagernet.org/configuration/shared/tls/#kernel_rx") + } + if options.Options.Reality != nil && options.Options.Reality.Enabled { + return NewRealityServer(options.Context, options.Logger, options.Options) + } + return NewSTDServer(options.Context, options.Logger, options.Options) +} + +func ServerHandshake(ctx context.Context, conn net.Conn, config ServerConfig) (Conn, error) { + ctx, cancel := context.WithTimeout(ctx, C.TCPTimeout) + defer cancel() + tlsConn, err := aTLS.ServerHandshake(ctx, conn, config) + if err != nil { + return nil, err + } + readWaitConn, err := badtls.NewReadWaitConn(tlsConn) + if err == nil { + return readWaitConn, nil + } else if err != os.ErrInvalid { + return nil, err + } + return tlsConn, nil +} diff --git a/common/tls/std_client.go b/common/tls/std_client.go new file mode 100644 index 00000000..1611c83e --- /dev/null +++ b/common/tls/std_client.go @@ -0,0 +1,240 @@ +package tls + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "net" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tlsfragment" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/ntp" +) + +type STDClientConfig struct { + ctx context.Context + config *tls.Config + fragment bool + fragmentFallbackDelay time.Duration + recordFragment bool +} + +func (c *STDClientConfig) ServerName() string { + return c.config.ServerName +} + +func (c *STDClientConfig) SetServerName(serverName string) { + c.config.ServerName = serverName +} + +func (c *STDClientConfig) NextProtos() []string { + return c.config.NextProtos +} + +func (c *STDClientConfig) SetNextProtos(nextProto []string) { + c.config.NextProtos = nextProto +} + +func (c *STDClientConfig) STDConfig() (*STDConfig, error) { + return c.config, nil +} + +func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) { + if c.recordFragment { + conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay) + } + return tls.Client(conn, c.config), nil +} + +func (c *STDClientConfig) Clone() Config { + return &STDClientConfig{ + ctx: c.ctx, + config: c.config.Clone(), + fragment: c.fragment, + fragmentFallbackDelay: c.fragmentFallbackDelay, + recordFragment: c.recordFragment, + } +} + +func (c *STDClientConfig) ECHConfigList() []byte { + return c.config.EncryptedClientHelloConfigList +} + +func (c *STDClientConfig) SetECHConfigList(EncryptedClientHelloConfigList []byte) { + c.config.EncryptedClientHelloConfigList = EncryptedClientHelloConfigList +} + +func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + var serverName string + if options.ServerName != "" { + serverName = options.ServerName + } else if serverAddress != "" { + serverName = serverAddress + } + if serverName == "" && !options.Insecure { + return nil, E.New("missing server_name or insecure=true") + } + + var tlsConfig tls.Config + tlsConfig.Time = ntp.TimeFuncFromContext(ctx) + tlsConfig.RootCAs = adapter.RootPoolFromContext(ctx) + if !options.DisableSNI { + tlsConfig.ServerName = serverName + } + if options.Insecure { + tlsConfig.InsecureSkipVerify = options.Insecure + } else if options.DisableSNI { + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { + verifyOptions := x509.VerifyOptions{ + Roots: tlsConfig.RootCAs, + DNSName: serverName, + Intermediates: x509.NewCertPool(), + } + for _, cert := range state.PeerCertificates[1:] { + verifyOptions.Intermediates.AddCert(cert) + } + if tlsConfig.Time != nil { + verifyOptions.CurrentTime = tlsConfig.Time() + } + _, err := state.PeerCertificates[0].Verify(verifyOptions) + return err + } + } + if len(options.CertificatePublicKeySHA256) > 0 { + if len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) + } + } + if len(options.ALPN) > 0 { + tlsConfig.NextProtos = options.ALPN + } + if options.MinVersion != "" { + minVersion, err := ParseTLSVersion(options.MinVersion) + if err != nil { + return nil, E.Cause(err, "parse min_version") + } + tlsConfig.MinVersion = minVersion + } + if options.MaxVersion != "" { + maxVersion, err := ParseTLSVersion(options.MaxVersion) + if err != nil { + return nil, E.Cause(err, "parse max_version") + } + tlsConfig.MaxVersion = maxVersion + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + for _, curve := range options.CurvePreferences { + tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curve)) + } + var certificate []byte + if len(options.Certificate) > 0 { + certificate = []byte(strings.Join(options.Certificate, "\n")) + } else if options.CertificatePath != "" { + content, err := os.ReadFile(options.CertificatePath) + if err != nil { + return nil, E.Cause(err, "read certificate") + } + certificate = content + } + if len(certificate) > 0 { + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(certificate) { + return nil, E.New("failed to parse certificate:\n\n", certificate) + } + tlsConfig.RootCAs = certPool + } + var clientCertificate []byte + if len(options.ClientCertificate) > 0 { + clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) + } else if options.ClientCertificatePath != "" { + content, err := os.ReadFile(options.ClientCertificatePath) + if err != nil { + return nil, E.Cause(err, "read client certificate") + } + clientCertificate = content + } + var clientKey []byte + if len(options.ClientKey) > 0 { + clientKey = []byte(strings.Join(options.ClientKey, "\n")) + } else if options.ClientKeyPath != "" { + content, err := os.ReadFile(options.ClientKeyPath) + if err != nil { + return nil, E.Cause(err, "read client key") + } + clientKey = content + } + if len(clientCertificate) > 0 && len(clientKey) > 0 { + keyPair, err := tls.X509KeyPair(clientCertificate, clientKey) + if err != nil { + return nil, E.Cause(err, "parse client x509 key pair") + } + tlsConfig.Certificates = []tls.Certificate{keyPair} + } else if len(clientCertificate) > 0 || len(clientKey) > 0 { + return nil, E.New("client certificate and client key must be provided together") + } + var config Config = &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment} + if options.ECH != nil && options.ECH.Enabled { + var err error + config, err = parseECHClientConfig(ctx, config.(ECHCapableConfig), options) + if err != nil { + return nil, err + } + } + if options.KernelRx || options.KernelTx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTLSClientConfig{ + Config: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} + +func verifyPublicKeySHA256(knownHashValues [][]byte, rawCerts [][]byte, timeFunc func() time.Time) error { + leafCertificate, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return E.Cause(err, "failed to parse leaf certificate") + } + + pubKeyBytes, err := x509.MarshalPKIXPublicKey(leafCertificate.PublicKey) + if err != nil { + return E.Cause(err, "failed to marshal public key") + } + hashValue := sha256.Sum256(pubKeyBytes) + for _, value := range knownHashValues { + if bytes.Equal(value, hashValue[:]) { + return nil + } + } + return E.New("unrecognized remote public key: ", base64.StdEncoding.EncodeToString(hashValue[:])) +} diff --git a/common/tls/std_server.go b/common/tls/std_server.go new file mode 100644 index 00000000..86584cd4 --- /dev/null +++ b/common/tls/std_server.go @@ -0,0 +1,528 @@ +package tls + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" +) + +var errInsecureUnused = E.New("tls: insecure unused") + +type managedCertificateProvider interface { + adapter.CertificateProvider + adapter.SimpleLifecycle +} + +type sharedCertificateProvider struct { + tag string + manager adapter.CertificateProviderManager + provider adapter.CertificateProviderService +} + +func (p *sharedCertificateProvider) Start() error { + provider, found := p.manager.Get(p.tag) + if !found { + return E.New("certificate provider not found: ", p.tag) + } + p.provider = provider + return nil +} + +func (p *sharedCertificateProvider) Close() error { + return nil +} + +func (p *sharedCertificateProvider) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return p.provider.GetCertificate(hello) +} + +func (p *sharedCertificateProvider) GetACMENextProtos() []string { + return getACMENextProtos(p.provider) +} + +type inlineCertificateProvider struct { + provider adapter.CertificateProviderService +} + +func (p *inlineCertificateProvider) Start() error { + for _, stage := range adapter.ListStartStages { + err := adapter.LegacyStart(p.provider, stage) + if err != nil { + return err + } + } + return nil +} + +func (p *inlineCertificateProvider) Close() error { + return p.provider.Close() +} + +func (p *inlineCertificateProvider) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return p.provider.GetCertificate(hello) +} + +func (p *inlineCertificateProvider) GetACMENextProtos() []string { + return getACMENextProtos(p.provider) +} + +func getACMENextProtos(provider adapter.CertificateProvider) []string { + if acmeProvider, isACME := provider.(adapter.ACMECertificateProvider); isACME { + return acmeProvider.GetACMENextProtos() + } + return nil +} + +type STDServerConfig struct { + access sync.RWMutex + config *tls.Config + logger log.Logger + certificateProvider managedCertificateProvider + acmeService adapter.SimpleLifecycle + certificate []byte + key []byte + certificatePath string + keyPath string + clientCertificatePath []string + echKeyPath string + watcher *fswatch.Watcher +} + +func (c *STDServerConfig) ServerName() string { + c.access.RLock() + defer c.access.RUnlock() + return c.config.ServerName +} + +func (c *STDServerConfig) SetServerName(serverName string) { + c.access.Lock() + defer c.access.Unlock() + config := c.config.Clone() + config.ServerName = serverName + c.config = config +} + +func (c *STDServerConfig) NextProtos() []string { + c.access.RLock() + defer c.access.RUnlock() + if c.hasACMEALPN() && len(c.config.NextProtos) > 1 && c.config.NextProtos[0] == C.ACMETLS1Protocol { + return c.config.NextProtos[1:] + } + return c.config.NextProtos +} + +func (c *STDServerConfig) SetNextProtos(nextProto []string) { + c.access.Lock() + defer c.access.Unlock() + config := c.config.Clone() + if c.hasACMEALPN() && len(c.config.NextProtos) > 1 && c.config.NextProtos[0] == C.ACMETLS1Protocol { + config.NextProtos = append(c.config.NextProtos[:1], nextProto...) + } else { + config.NextProtos = nextProto + } + c.config = config +} + +func (c *STDServerConfig) hasACMEALPN() bool { + if c.acmeService != nil { + return true + } + if c.certificateProvider != nil { + if acmeProvider, isACME := c.certificateProvider.(adapter.ACMECertificateProvider); isACME { + return len(acmeProvider.GetACMENextProtos()) > 0 + } + } + return false +} + +func (c *STDServerConfig) STDConfig() (*STDConfig, error) { + return c.config, nil +} + +func (c *STDServerConfig) Client(conn net.Conn) (Conn, error) { + return tls.Client(conn, c.config), nil +} + +func (c *STDServerConfig) Server(conn net.Conn) (Conn, error) { + return tls.Server(conn, c.config), nil +} + +func (c *STDServerConfig) Clone() Config { + return &STDServerConfig{ + config: c.config.Clone(), + } +} + +func (c *STDServerConfig) Start() error { + if c.certificateProvider != nil { + err := c.certificateProvider.Start() + if err != nil { + return err + } + if acmeProvider, isACME := c.certificateProvider.(adapter.ACMECertificateProvider); isACME { + nextProtos := acmeProvider.GetACMENextProtos() + if len(nextProtos) > 0 { + c.access.Lock() + config := c.config.Clone() + mergedNextProtos := append([]string{}, nextProtos...) + for _, nextProto := range config.NextProtos { + if !common.Contains(mergedNextProtos, nextProto) { + mergedNextProtos = append(mergedNextProtos, nextProto) + } + } + config.NextProtos = mergedNextProtos + c.config = config + c.access.Unlock() + } + } + } + if c.acmeService != nil { + err := c.acmeService.Start() + if err != nil { + return err + } + } + err := c.startWatcher() + if err != nil { + c.logger.Warn("create fsnotify watcher: ", err) + } + return nil +} + +func (c *STDServerConfig) startWatcher() error { + var watchPath []string + if c.certificatePath != "" { + watchPath = append(watchPath, c.certificatePath) + } + if c.keyPath != "" { + watchPath = append(watchPath, c.keyPath) + } + if c.echKeyPath != "" { + watchPath = append(watchPath, c.echKeyPath) + } + if len(c.clientCertificatePath) > 0 { + watchPath = append(watchPath, c.clientCertificatePath...) + } + if len(watchPath) == 0 { + return nil + } + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: watchPath, + Callback: func(path string) { + err := c.certificateUpdated(path) + if err != nil { + c.logger.Error(E.Cause(err, "reload certificate")) + } + }, + }) + if err != nil { + return err + } + err = watcher.Start() + if err != nil { + return err + } + c.watcher = watcher + return nil +} + +func (c *STDServerConfig) certificateUpdated(path string) error { + if path == c.certificatePath || path == c.keyPath { + if path == c.certificatePath { + certificate, err := os.ReadFile(c.certificatePath) + if err != nil { + return E.Cause(err, "reload certificate from ", c.certificatePath) + } + c.certificate = certificate + } else if path == c.keyPath { + key, err := os.ReadFile(c.keyPath) + if err != nil { + return E.Cause(err, "reload key from ", c.keyPath) + } + c.key = key + } + keyPair, err := tls.X509KeyPair(c.certificate, c.key) + if err != nil { + return E.Cause(err, "reload key pair") + } + c.access.Lock() + config := c.config.Clone() + config.Certificates = []tls.Certificate{keyPair} + c.config = config + c.access.Unlock() + c.logger.Info("reloaded TLS certificate") + } else if common.Contains(c.clientCertificatePath, path) { + clientCertificateCA := x509.NewCertPool() + var reloaded bool + for _, certPath := range c.clientCertificatePath { + content, err := os.ReadFile(certPath) + if err != nil { + c.logger.Error(E.Cause(err, "reload certificate from ", c.clientCertificatePath)) + continue + } + if !clientCertificateCA.AppendCertsFromPEM(content) { + c.logger.Error(E.New("invalid client certificate file: ", certPath)) + continue + } + reloaded = true + } + if !reloaded { + return E.New("client certificates is empty") + } + c.access.Lock() + config := c.config.Clone() + config.ClientCAs = clientCertificateCA + c.config = config + c.access.Unlock() + c.logger.Info("reloaded client certificates") + } else if path == c.echKeyPath { + echKey, err := os.ReadFile(c.echKeyPath) + if err != nil { + return E.Cause(err, "reload ECH keys from ", c.echKeyPath) + } + err = c.setECHServerConfig(echKey) + if err != nil { + return err + } + c.logger.Info("reloaded ECH keys") + } + return nil +} + +func (c *STDServerConfig) Close() error { + return common.Close(c.certificateProvider, c.acmeService, c.watcher) +} + +func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { + if !options.Enabled { + return nil, nil + } + //nolint:staticcheck + if options.CertificateProvider != nil && options.ACME != nil { + return nil, E.New("certificate_provider and acme are mutually exclusive") + } + var tlsConfig *tls.Config + var certificateProvider managedCertificateProvider + var acmeService adapter.SimpleLifecycle + var err error + if options.CertificateProvider != nil { + certificateProvider, err = newCertificateProvider(ctx, logger, options.CertificateProvider) + if err != nil { + return nil, err + } + tlsConfig = &tls.Config{ + GetCertificate: certificateProvider.GetCertificate, + } + if options.Insecure { + return nil, errInsecureUnused + } + } else if options.ACME != nil && len(options.ACME.Domain) > 0 { //nolint:staticcheck + deprecated.Report(ctx, deprecated.OptionInlineACME) + //nolint:staticcheck + tlsConfig, acmeService, err = startACME(ctx, logger, common.PtrValueOrDefault(options.ACME)) + if err != nil { + return nil, err + } + if options.Insecure { + return nil, errInsecureUnused + } + } else { + tlsConfig = &tls.Config{} + } + tlsConfig.Time = ntp.TimeFuncFromContext(ctx) + if options.ServerName != "" { + tlsConfig.ServerName = options.ServerName + } + if len(options.ALPN) > 0 { + tlsConfig.NextProtos = append(options.ALPN, tlsConfig.NextProtos...) + } + if options.MinVersion != "" { + minVersion, err := ParseTLSVersion(options.MinVersion) + if err != nil { + return nil, E.Cause(err, "parse min_version") + } + tlsConfig.MinVersion = minVersion + } + if options.MaxVersion != "" { + maxVersion, err := ParseTLSVersion(options.MaxVersion) + if err != nil { + return nil, E.Cause(err, "parse max_version") + } + tlsConfig.MaxVersion = maxVersion + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + for _, curveID := range options.CurvePreferences { + tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curveID)) + } + tlsConfig.ClientAuth = tls.ClientAuthType(options.ClientAuthentication) + var ( + certificate []byte + key []byte + ) + if certificateProvider == nil && acmeService == nil { + if len(options.Certificate) > 0 { + certificate = []byte(strings.Join(options.Certificate, "\n")) + } else if options.CertificatePath != "" { + content, err := os.ReadFile(options.CertificatePath) + if err != nil { + return nil, E.Cause(err, "read certificate") + } + certificate = content + } + if len(options.Key) > 0 { + key = []byte(strings.Join(options.Key, "\n")) + } else if options.KeyPath != "" { + content, err := os.ReadFile(options.KeyPath) + if err != nil { + return nil, E.Cause(err, "read key") + } + key = content + } + if certificate == nil && key == nil && options.Insecure { + timeFunc := ntp.TimeFuncFromContext(ctx) + if timeFunc == nil { + timeFunc = time.Now + } + tlsConfig.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + return GenerateKeyPair(nil, nil, timeFunc, info.ServerName) + } + } else { + if certificate == nil { + return nil, E.New("missing certificate") + } else if key == nil { + return nil, E.New("missing key") + } + + keyPair, err := tls.X509KeyPair(certificate, key) + if err != nil { + return nil, E.Cause(err, "parse x509 key pair") + } + tlsConfig.Certificates = []tls.Certificate{keyPair} + } + } + if len(options.ClientCertificate) > 0 || len(options.ClientCertificatePath) > 0 { + if tlsConfig.ClientAuth == tls.NoClientCert { + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + } + } + if tlsConfig.ClientAuth == tls.VerifyClientCertIfGiven || tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert { + if len(options.ClientCertificate) > 0 { + clientCertificateCA := x509.NewCertPool() + if !clientCertificateCA.AppendCertsFromPEM([]byte(strings.Join(options.ClientCertificate, "\n"))) { + return nil, E.New("invalid client certificate strings") + } + tlsConfig.ClientCAs = clientCertificateCA + } else if len(options.ClientCertificatePath) > 0 { + clientCertificateCA := x509.NewCertPool() + for _, path := range options.ClientCertificatePath { + content, err := os.ReadFile(path) + if err != nil { + return nil, E.Cause(err, "read client certificate from ", path) + } + if !clientCertificateCA.AppendCertsFromPEM(content) { + return nil, E.New("invalid client certificate file: ", path) + } + } + tlsConfig.ClientCAs = clientCertificateCA + } else if len(options.ClientCertificatePublicKeySHA256) > 0 { + if tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert { + tlsConfig.ClientAuth = tls.RequireAnyClientCert + } else if tlsConfig.ClientAuth == tls.VerifyClientCertIfGiven { + tlsConfig.ClientAuth = tls.RequestClientCert + } + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + return verifyPublicKeySHA256(options.ClientCertificatePublicKeySHA256, rawCerts, tlsConfig.Time) + } + } else { + return nil, E.New("missing client_certificate, client_certificate_path or client_certificate_public_key_sha256 for client authentication") + } + } + var echKeyPath string + if options.ECH != nil && options.ECH.Enabled { + err = parseECHServerConfig(ctx, options, tlsConfig, &echKeyPath) + if err != nil { + return nil, err + } + } + serverConfig := &STDServerConfig{ + config: tlsConfig, + logger: logger, + certificateProvider: certificateProvider, + acmeService: acmeService, + certificate: certificate, + key: key, + certificatePath: options.CertificatePath, + clientCertificatePath: options.ClientCertificatePath, + keyPath: options.KeyPath, + echKeyPath: echKeyPath, + } + serverConfig.config.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) { + serverConfig.access.RLock() + defer serverConfig.access.RUnlock() + return serverConfig.config, nil + } + var config ServerConfig = serverConfig + if options.KernelTx || options.KernelRx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTlSServerConfig{ + ServerConfig: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} + +func newCertificateProvider(ctx context.Context, logger log.ContextLogger, options *option.CertificateProviderOptions) (managedCertificateProvider, error) { + if options.IsShared() { + manager := service.FromContext[adapter.CertificateProviderManager](ctx) + if manager == nil { + return nil, E.New("missing certificate provider manager in context") + } + return &sharedCertificateProvider{ + tag: options.Tag, + manager: manager, + }, nil + } + registry := service.FromContext[adapter.CertificateProviderRegistry](ctx) + if registry == nil { + return nil, E.New("missing certificate provider registry in context") + } + provider, err := registry.Create(ctx, logger, "", options.Type, options.Options) + if err != nil { + return nil, E.Cause(err, "create inline certificate provider") + } + return &inlineCertificateProvider{ + provider: provider, + }, nil +} diff --git a/common/tls/time_wrapper.go b/common/tls/time_wrapper.go new file mode 100644 index 00000000..5cbecedc --- /dev/null +++ b/common/tls/time_wrapper.go @@ -0,0 +1,25 @@ +package tls + +import ( + "time" + + "github.com/sagernet/sing/common/ntp" +) + +type TimeServiceWrapper struct { + ntp.TimeService +} + +func (w *TimeServiceWrapper) TimeFunc() func() time.Time { + return func() time.Time { + if w.TimeService != nil { + return w.TimeService.TimeFunc()() + } else { + return time.Now() + } + } +} + +func (w *TimeServiceWrapper) Upstream() any { + return w.TimeService +} diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go new file mode 100644 index 00000000..941192ba --- /dev/null +++ b/common/tls/utls_client.go @@ -0,0 +1,332 @@ +//go:build with_utls + +package tls + +import ( + "context" + "crypto/tls" + "crypto/x509" + "math/rand" + "net" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tlsfragment" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/ntp" + + utls "github.com/metacubex/utls" + "golang.org/x/net/http2" +) + +type UTLSClientConfig struct { + ctx context.Context + config *utls.Config + id utls.ClientHelloID + fragment bool + fragmentFallbackDelay time.Duration + recordFragment bool +} + +func (c *UTLSClientConfig) ServerName() string { + return c.config.ServerName +} + +func (c *UTLSClientConfig) SetServerName(serverName string) { + c.config.ServerName = serverName +} + +func (c *UTLSClientConfig) NextProtos() []string { + return c.config.NextProtos +} + +func (c *UTLSClientConfig) SetNextProtos(nextProto []string) { + if len(nextProto) == 1 && nextProto[0] == http2.NextProtoTLS { + nextProto = append(nextProto, "http/1.1") + } + c.config.NextProtos = nextProto +} + +func (c *UTLSClientConfig) STDConfig() (*STDConfig, error) { + return nil, E.New("unsupported usage for uTLS") +} + +func (c *UTLSClientConfig) Client(conn net.Conn) (Conn, error) { + if c.recordFragment { + conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay) + } + return &utlsALPNWrapper{utlsConnWrapper{utls.UClient(conn, c.config.Clone(), c.id)}, c.config.NextProtos}, nil +} + +func (c *UTLSClientConfig) SetSessionIDGenerator(generator func(clientHello []byte, sessionID []byte) error) { + c.config.SessionIDGenerator = generator +} + +func (c *UTLSClientConfig) Clone() Config { + return &UTLSClientConfig{ + c.ctx, c.config.Clone(), c.id, c.fragment, c.fragmentFallbackDelay, c.recordFragment, + } +} + +func (c *UTLSClientConfig) ECHConfigList() []byte { + return c.config.EncryptedClientHelloConfigList +} + +func (c *UTLSClientConfig) SetECHConfigList(EncryptedClientHelloConfigList []byte) { + c.config.EncryptedClientHelloConfigList = EncryptedClientHelloConfigList +} + +type utlsConnWrapper struct { + *utls.UConn +} + +func (c *utlsConnWrapper) ConnectionState() tls.ConnectionState { + state := c.Conn.ConnectionState() + //nolint:staticcheck + return tls.ConnectionState{ + Version: state.Version, + HandshakeComplete: state.HandshakeComplete, + DidResume: state.DidResume, + CipherSuite: state.CipherSuite, + NegotiatedProtocol: state.NegotiatedProtocol, + NegotiatedProtocolIsMutual: state.NegotiatedProtocolIsMutual, + ServerName: state.ServerName, + PeerCertificates: state.PeerCertificates, + VerifiedChains: state.VerifiedChains, + SignedCertificateTimestamps: state.SignedCertificateTimestamps, + OCSPResponse: state.OCSPResponse, + TLSUnique: state.TLSUnique, + } +} + +func (c *utlsConnWrapper) Upstream() any { + return c.UConn +} + +func (c *utlsConnWrapper) ReaderReplaceable() bool { + return true +} + +func (c *utlsConnWrapper) WriterReplaceable() bool { + return true +} + +type utlsALPNWrapper struct { + utlsConnWrapper + nextProtocols []string +} + +func (c *utlsALPNWrapper) HandshakeContext(ctx context.Context) error { + if len(c.nextProtocols) > 0 { + err := c.BuildHandshakeState() + if err != nil { + return err + } + for _, extension := range c.Extensions { + if alpnExtension, isALPN := extension.(*utls.ALPNExtension); isALPN { + alpnExtension.AlpnProtocols = c.nextProtocols + err = c.BuildHandshakeState() + if err != nil { + return err + } + break + } + } + } + return c.UConn.HandshakeContext(ctx) +} + +func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + var serverName string + if options.ServerName != "" { + serverName = options.ServerName + } else if serverAddress != "" { + serverName = serverAddress + } + if serverName == "" && !options.Insecure { + return nil, E.New("missing server_name or insecure=true") + } + + var tlsConfig utls.Config + tlsConfig.Time = ntp.TimeFuncFromContext(ctx) + tlsConfig.RootCAs = adapter.RootPoolFromContext(ctx) + if !options.DisableSNI { + tlsConfig.ServerName = serverName + } + if options.Insecure { + tlsConfig.InsecureSkipVerify = options.Insecure + } else if options.DisableSNI { + if options.Reality != nil && options.Reality.Enabled { + return nil, E.New("disable_sni is unsupported in reality") + } + tlsConfig.InsecureServerNameToVerify = serverName + } + if len(options.CertificatePublicKeySHA256) > 0 { + if len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) + } + } + if len(options.ALPN) > 0 { + tlsConfig.NextProtos = options.ALPN + } + if options.MinVersion != "" { + minVersion, err := ParseTLSVersion(options.MinVersion) + if err != nil { + return nil, E.Cause(err, "parse min_version") + } + tlsConfig.MinVersion = minVersion + } + if options.MaxVersion != "" { + maxVersion, err := ParseTLSVersion(options.MaxVersion) + if err != nil { + return nil, E.Cause(err, "parse max_version") + } + tlsConfig.MaxVersion = maxVersion + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + var certificate []byte + if len(options.Certificate) > 0 { + certificate = []byte(strings.Join(options.Certificate, "\n")) + } else if options.CertificatePath != "" { + content, err := os.ReadFile(options.CertificatePath) + if err != nil { + return nil, E.Cause(err, "read certificate") + } + certificate = content + } + if len(certificate) > 0 { + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(certificate) { + return nil, E.New("failed to parse certificate:\n\n", certificate) + } + tlsConfig.RootCAs = certPool + } + var clientCertificate []byte + if len(options.ClientCertificate) > 0 { + clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) + } else if options.ClientCertificatePath != "" { + content, err := os.ReadFile(options.ClientCertificatePath) + if err != nil { + return nil, E.Cause(err, "read client certificate") + } + clientCertificate = content + } + var clientKey []byte + if len(options.ClientKey) > 0 { + clientKey = []byte(strings.Join(options.ClientKey, "\n")) + } else if options.ClientKeyPath != "" { + content, err := os.ReadFile(options.ClientKeyPath) + if err != nil { + return nil, E.Cause(err, "read client key") + } + clientKey = content + } + if len(clientCertificate) > 0 && len(clientKey) > 0 { + keyPair, err := utls.X509KeyPair(clientCertificate, clientKey) + if err != nil { + return nil, E.Cause(err, "parse client x509 key pair") + } + tlsConfig.Certificates = []utls.Certificate{keyPair} + } else if len(clientCertificate) > 0 || len(clientKey) > 0 { + return nil, E.New("client certificate and client key must be provided together") + } + id, err := uTLSClientHelloID(options.UTLS.Fingerprint) + if err != nil { + return nil, err + } + var config Config = &UTLSClientConfig{ctx, &tlsConfig, id, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment} + if options.ECH != nil && options.ECH.Enabled { + if options.Reality != nil && options.Reality.Enabled { + return nil, E.New("Reality is conflict with ECH") + } + config, err = parseECHClientConfig(ctx, config.(ECHCapableConfig), options) + if err != nil { + return nil, err + } + } + if (options.KernelRx || options.KernelTx) && !common.PtrValueOrDefault(options.Reality).Enabled { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTLSClientConfig{ + Config: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} + +var ( + randomFingerprint utls.ClientHelloID + randomizedFingerprint utls.ClientHelloID +) + +func init() { + modernFingerprints := []utls.ClientHelloID{ + utls.HelloChrome_Auto, + utls.HelloFirefox_Auto, + utls.HelloEdge_Auto, + utls.HelloSafari_Auto, + utls.HelloIOS_Auto, + } + randomFingerprint = modernFingerprints[rand.Intn(len(modernFingerprints))] + + weights := utls.DefaultWeights + weights.TLSVersMax_Set_VersionTLS13 = 1 + weights.FirstKeyShare_Set_CurveP256 = 0 + randomizedFingerprint = utls.HelloRandomized + randomizedFingerprint.Seed, _ = utls.NewPRNGSeed() + randomizedFingerprint.Weights = &weights +} + +func uTLSClientHelloID(name string) (utls.ClientHelloID, error) { + switch name { + case "chrome_psk", "chrome_psk_shuffle", "chrome_padding_psk_shuffle", "chrome_pq", "chrome_pq_psk": + fallthrough + case "chrome", "": + return utls.HelloChrome_Auto, nil + case "firefox": + return utls.HelloFirefox_Auto, nil + case "edge": + return utls.HelloEdge_Auto, nil + case "safari": + return utls.HelloSafari_Auto, nil + case "360": + return utls.Hello360_Auto, nil + case "qq": + return utls.HelloQQ_Auto, nil + case "ios": + return utls.HelloIOS_Auto, nil + case "android": + return utls.HelloAndroid_11_OkHttp, nil + case "random": + return randomFingerprint, nil + case "randomized": + return randomizedFingerprint, nil + default: + return utls.ClientHelloID{}, E.New("unknown uTLS fingerprint: ", name) + } +} diff --git a/common/tls/utls_stub.go b/common/tls/utls_stub.go new file mode 100644 index 00000000..3eddd28e --- /dev/null +++ b/common/tls/utls_stub.go @@ -0,0 +1,24 @@ +//go:build !with_utls + +package tls + +import ( + "context" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + return nil, E.New(`uTLS is not included in this build, rebuild with -tags with_utls`) +} + +func NewRealityClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + return nil, E.New(`uTLS, which is required by reality is not included in this build, rebuild with -tags with_utls`) +} + +func NewRealityServer(ctx context.Context, logger log.Logger, options option.InboundTLSOptions) (ServerConfig, error) { + return nil, E.New(`uTLS, which is required by reality is not included in this build, rebuild with -tags with_utls`) +} diff --git a/common/tlsfragment/conn.go b/common/tlsfragment/conn.go new file mode 100644 index 00000000..040663bd --- /dev/null +++ b/common/tlsfragment/conn.go @@ -0,0 +1,146 @@ +package tf + +import ( + "bytes" + "context" + "encoding/binary" + "math/rand" + "net" + "strings" + "time" + + C "github.com/sagernet/sing-box/constant" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/publicsuffix" +) + +type Conn struct { + net.Conn + tcpConn *net.TCPConn + ctx context.Context + firstPacketWritten bool + splitPacket bool + splitRecord bool + fallbackDelay time.Duration +} + +func NewConn(conn net.Conn, ctx context.Context, splitPacket bool, splitRecord bool, fallbackDelay time.Duration) *Conn { + if fallbackDelay == 0 { + fallbackDelay = C.TLSFragmentFallbackDelay + } + tcpConn, _ := N.UnwrapReader(conn).(*net.TCPConn) + return &Conn{ + Conn: conn, + tcpConn: tcpConn, + ctx: ctx, + splitPacket: splitPacket, + splitRecord: splitRecord, + fallbackDelay: fallbackDelay, + } +} + +func (c *Conn) Write(b []byte) (n int, err error) { + if !c.firstPacketWritten { + defer func() { + c.firstPacketWritten = true + }() + serverName := IndexTLSServerName(b) + if serverName != nil { + if c.splitPacket { + if c.tcpConn != nil { + err = c.tcpConn.SetNoDelay(true) + if err != nil { + return + } + } + } + splits := strings.Split(serverName.ServerName, ".") + currentIndex := serverName.Index + if publicSuffix := publicsuffix.List.PublicSuffix(serverName.ServerName); publicSuffix != "" { + splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")] + } + if len(splits) > 1 && splits[0] == "..." { + currentIndex += len(splits[0]) + 1 + splits = splits[1:] + } + var splitIndexes []int + for i, split := range splits { + splitAt := rand.Intn(len(split)) + splitIndexes = append(splitIndexes, currentIndex+splitAt) + currentIndex += len(split) + if i != len(splits)-1 { + currentIndex++ + } + } + var buffer bytes.Buffer + for i := 0; i <= len(splitIndexes); i++ { + var payload []byte + if i == 0 { + payload = b[:splitIndexes[i]] + if c.splitRecord { + payload = payload[recordLayerHeaderLen:] + } + } else if i == len(splitIndexes) { + payload = b[splitIndexes[i-1]:] + } else { + payload = b[splitIndexes[i-1]:splitIndexes[i]] + } + if c.splitRecord { + if c.splitPacket { + buffer.Reset() + } + payloadLen := uint16(len(payload)) + buffer.Write(b[:3]) + binary.Write(&buffer, binary.BigEndian, payloadLen) + buffer.Write(payload) + if c.splitPacket { + payload = buffer.Bytes() + } + } + if c.splitPacket { + if c.tcpConn != nil && i != len(splitIndexes) { + err = writeAndWaitAck(c.ctx, c.tcpConn, payload, c.fallbackDelay) + if err != nil { + return + } + } else { + _, err = c.Conn.Write(payload) + if err != nil { + return + } + if i != len(splitIndexes) { + time.Sleep(c.fallbackDelay) + } + } + } + } + if c.splitRecord && !c.splitPacket { + _, err = c.Conn.Write(buffer.Bytes()) + if err != nil { + return + } + } + if c.tcpConn != nil { + err = c.tcpConn.SetNoDelay(false) + if err != nil { + return + } + } + return len(b), nil + } + } + return c.Conn.Write(b) +} + +func (c *Conn) ReaderReplaceable() bool { + return true +} + +func (c *Conn) WriterReplaceable() bool { + return c.firstPacketWritten +} + +func (c *Conn) Upstream() any { + return c.Conn +} diff --git a/common/tlsfragment/conn_test.go b/common/tlsfragment/conn_test.go new file mode 100644 index 00000000..b7ade873 --- /dev/null +++ b/common/tlsfragment/conn_test.go @@ -0,0 +1,42 @@ +package tf_test + +import ( + "context" + "crypto/tls" + "net" + "testing" + + tf "github.com/sagernet/sing-box/common/tlsfragment" + + "github.com/stretchr/testify/require" +) + +func TestTLSFragment(t *testing.T) { + t.Parallel() + tcpConn, err := net.Dial("tcp", "1.1.1.1:443") + require.NoError(t, err) + tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), true, false, 0), &tls.Config{ + ServerName: "www.cloudflare.com", + }) + require.NoError(t, tlsConn.Handshake()) +} + +func TestTLSRecordFragment(t *testing.T) { + t.Parallel() + tcpConn, err := net.Dial("tcp", "1.1.1.1:443") + require.NoError(t, err) + tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), false, true, 0), &tls.Config{ + ServerName: "www.cloudflare.com", + }) + require.NoError(t, tlsConn.Handshake()) +} + +func TestTLS2Fragment(t *testing.T) { + t.Parallel() + tcpConn, err := net.Dial("tcp", "1.1.1.1:443") + require.NoError(t, err) + tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), true, true, 0), &tls.Config{ + ServerName: "www.cloudflare.com", + }) + require.NoError(t, tlsConn.Handshake()) +} diff --git a/common/tlsfragment/index.go b/common/tlsfragment/index.go new file mode 100644 index 00000000..0d58c445 --- /dev/null +++ b/common/tlsfragment/index.go @@ -0,0 +1,133 @@ +package tf + +import ( + "encoding/binary" +) + +const ( + recordLayerHeaderLen int = 5 + handshakeHeaderLen int = 6 + randomDataLen int = 32 + sessionIDHeaderLen int = 1 + cipherSuiteHeaderLen int = 2 + compressMethodHeaderLen int = 1 + extensionsHeaderLen int = 2 + extensionHeaderLen int = 4 + sniExtensionHeaderLen int = 5 + contentType uint8 = 22 + handshakeType uint8 = 1 + sniExtensionType uint16 = 0 + sniNameDNSHostnameType uint8 = 0 + tlsVersionBitmask uint16 = 0xFFFC + tls13 uint16 = 0x0304 +) + +type MyServerName struct { + Index int + Length int + ServerName string +} + +func IndexTLSServerName(payload []byte) *MyServerName { + if len(payload) < recordLayerHeaderLen || payload[0] != contentType { + return nil + } + segmentLen := binary.BigEndian.Uint16(payload[3:5]) + if len(payload) < recordLayerHeaderLen+int(segmentLen) { + return nil + } + serverName := indexTLSServerNameFromHandshake(payload[recordLayerHeaderLen:]) + if serverName == nil { + return nil + } + serverName.Index += recordLayerHeaderLen + return serverName +} + +func indexTLSServerNameFromHandshake(handshake []byte) *MyServerName { + if len(handshake) < handshakeHeaderLen+randomDataLen+sessionIDHeaderLen { + return nil + } + if handshake[0] != handshakeType { + return nil + } + handshakeLen := uint32(handshake[1])<<16 | uint32(handshake[2])<<8 | uint32(handshake[3]) + if len(handshake[4:]) != int(handshakeLen) { + return nil + } + tlsVersion := uint16(handshake[4])<<8 | uint16(handshake[5]) + if tlsVersion&tlsVersionBitmask != 0x0300 && tlsVersion != tls13 { + return nil + } + sessionIDLen := handshake[38] + currentIndex := handshakeHeaderLen + randomDataLen + sessionIDHeaderLen + int(sessionIDLen) + if len(handshake) < currentIndex { + return nil + } + cipherSuites := handshake[currentIndex:] + if len(cipherSuites) < cipherSuiteHeaderLen { + return nil + } + csLen := uint16(cipherSuites[0])<<8 | uint16(cipherSuites[1]) + if len(cipherSuites) < cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen { + return nil + } + compressMethodLen := uint16(cipherSuites[cipherSuiteHeaderLen+int(csLen)]) + currentIndex += cipherSuiteHeaderLen + int(csLen) + compressMethodHeaderLen + int(compressMethodLen) + if len(handshake) < currentIndex { + return nil + } + serverName := indexTLSServerNameFromExtensions(handshake[currentIndex:]) + if serverName == nil { + return nil + } + serverName.Index += currentIndex + return serverName +} + +func indexTLSServerNameFromExtensions(exs []byte) *MyServerName { + if len(exs) == 0 { + return nil + } + if len(exs) < extensionsHeaderLen { + return nil + } + exsLen := uint16(exs[0])<<8 | uint16(exs[1]) + exs = exs[extensionsHeaderLen:] + if len(exs) < int(exsLen) { + return nil + } + for currentIndex := extensionsHeaderLen; len(exs) > 0; { + if len(exs) < extensionHeaderLen { + return nil + } + exType := uint16(exs[0])<<8 | uint16(exs[1]) + exLen := uint16(exs[2])<<8 | uint16(exs[3]) + if len(exs) < extensionHeaderLen+int(exLen) { + return nil + } + sex := exs[extensionHeaderLen : extensionHeaderLen+int(exLen)] + + switch exType { + case sniExtensionType: + if len(sex) < sniExtensionHeaderLen { + return nil + } + sniType := sex[2] + if sniType != sniNameDNSHostnameType { + return nil + } + sniLen := uint16(sex[3])<<8 | uint16(sex[4]) + sex = sex[sniExtensionHeaderLen:] + + return &MyServerName{ + Index: currentIndex + extensionHeaderLen + sniExtensionHeaderLen, + Length: int(sniLen), + ServerName: string(sex), + } + } + exs = exs[4+exLen:] + currentIndex += 4 + int(exLen) + } + return nil +} diff --git a/common/tlsfragment/index_test.go b/common/tlsfragment/index_test.go new file mode 100644 index 00000000..5086d6c7 --- /dev/null +++ b/common/tlsfragment/index_test.go @@ -0,0 +1,20 @@ +package tf_test + +import ( + "encoding/hex" + "testing" + + "github.com/sagernet/sing-box/common/tlsfragment" + + "github.com/stretchr/testify/require" +) + +func TestIndexTLSServerName(t *testing.T) { + t.Parallel() + payload, err := hex.DecodeString("16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100") + require.NoError(t, err) + serverName := tf.IndexTLSServerName(payload) + require.NotNil(t, serverName) + require.Equal(t, serverName.ServerName, string(payload[serverName.Index:serverName.Index+serverName.Length])) + require.Equal(t, "github.com", serverName.ServerName) +} diff --git a/common/tlsfragment/wait_darwin.go b/common/tlsfragment/wait_darwin.go new file mode 100644 index 00000000..90c65ba2 --- /dev/null +++ b/common/tlsfragment/wait_darwin.go @@ -0,0 +1,93 @@ +package tf + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing/common/control" + + "golang.org/x/sys/unix" +) + +/* +const tcpMaxNotifyAck = 10 + +type tcpNotifyAckID uint32 + + type tcpNotifyAckComplete struct { + NotifyPending uint32 + NotifyCompleteCount uint32 + NotifyCompleteID [tcpMaxNotifyAck]tcpNotifyAckID + } + +var sizeOfTCPNotifyAckComplete = int(unsafe.Sizeof(tcpNotifyAckComplete{})) + + func getsockoptTCPNotifyAckComplete(fd, level, opt int) (*tcpNotifyAckComplete, error) { + var value tcpNotifyAckComplete + vallen := uint32(sizeOfTCPNotifyAckComplete) + err := getsockopt(fd, level, opt, unsafe.Pointer(&value), &vallen) + return &value, err + } + +//go:linkname getsockopt golang.org/x/sys/unix.getsockopt +func getsockopt(s int, level int, name int, val unsafe.Pointer, vallen *uint32) error + + func waitAck(ctx context.Context, conn *net.TCPConn, _ time.Duration) error { + const TCP_NOTIFY_ACKNOWLEDGEMENT = 0x212 + return control.Conn(conn, func(fd uintptr) error { + err := unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, TCP_NOTIFY_ACKNOWLEDGEMENT, 1) + if err != nil { + if errors.Is(err, unix.EINVAL) { + return waitAckFallback(ctx, conn, 0) + } + return err + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + var ackComplete *tcpNotifyAckComplete + ackComplete, err = getsockoptTCPNotifyAckComplete(int(fd), unix.IPPROTO_TCP, TCP_NOTIFY_ACKNOWLEDGEMENT) + if err != nil { + return err + } + if ackComplete.NotifyPending == 0 { + return nil + } + time.Sleep(10 * time.Millisecond) + } + }) + } +*/ + +func writeAndWaitAck(ctx context.Context, conn *net.TCPConn, payload []byte, fallbackDelay time.Duration) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + return control.Conn(conn, func(fd uintptr) error { + start := time.Now() + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + unacked, err := unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_NWRITE) + if err != nil { + return err + } + if unacked == 0 { + if time.Since(start) <= 20*time.Millisecond { + // under transparent proxy + time.Sleep(fallbackDelay) + } + return nil + } + time.Sleep(10 * time.Millisecond) + } + }) +} diff --git a/common/tlsfragment/wait_linux.go b/common/tlsfragment/wait_linux.go new file mode 100644 index 00000000..517d6ea5 --- /dev/null +++ b/common/tlsfragment/wait_linux.go @@ -0,0 +1,40 @@ +package tf + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing/common/control" + + "golang.org/x/sys/unix" +) + +func writeAndWaitAck(ctx context.Context, conn *net.TCPConn, payload []byte, fallbackDelay time.Duration) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + return control.Conn(conn, func(fd uintptr) error { + start := time.Now() + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + tcpInfo, err := unix.GetsockoptTCPInfo(int(fd), unix.IPPROTO_TCP, unix.TCP_INFO) + if err != nil { + return err + } + if tcpInfo.Unacked == 0 { + if time.Since(start) <= 20*time.Millisecond { + // under transparent proxy + time.Sleep(fallbackDelay) + } + return nil + } + time.Sleep(10 * time.Millisecond) + } + }) +} diff --git a/common/tlsfragment/wait_stub.go b/common/tlsfragment/wait_stub.go new file mode 100644 index 00000000..6a1cd889 --- /dev/null +++ b/common/tlsfragment/wait_stub.go @@ -0,0 +1,18 @@ +//go:build !(linux || darwin || windows) + +package tf + +import ( + "context" + "net" + "time" +) + +func writeAndWaitAck(ctx context.Context, conn *net.TCPConn, payload []byte, fallbackDelay time.Duration) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + time.Sleep(fallbackDelay) + return nil +} diff --git a/common/tlsfragment/wait_windows.go b/common/tlsfragment/wait_windows.go new file mode 100644 index 00000000..49706ca5 --- /dev/null +++ b/common/tlsfragment/wait_windows.go @@ -0,0 +1,31 @@ +package tf + +import ( + "context" + "errors" + "net" + "time" + + "github.com/sagernet/sing/common/winiphlpapi" + + "golang.org/x/sys/windows" +) + +func writeAndWaitAck(ctx context.Context, conn *net.TCPConn, payload []byte, fallbackDelay time.Duration) error { + start := time.Now() + err := winiphlpapi.WriteAndWaitAck(ctx, conn, payload) + if err != nil { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + if _, err := conn.Write(payload); err != nil { + return err + } + time.Sleep(fallbackDelay) + return nil + } + return err + } + if time.Since(start) <= 20*time.Millisecond { + time.Sleep(fallbackDelay) + } + return nil +} diff --git a/common/uot/router.go b/common/uot/router.go new file mode 100644 index 00000000..98c6d608 --- /dev/null +++ b/common/uot/router.go @@ -0,0 +1,86 @@ +package uot + +import ( + "context" + "net" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" +) + +var _ adapter.ConnectionRouterEx = (*Router)(nil) + +type Router struct { + router adapter.ConnectionRouterEx + logger logger.ContextLogger +} + +func NewRouter(router adapter.ConnectionRouterEx, logger logger.ContextLogger) *Router { + return &Router{router, logger} +} + +func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + switch metadata.Destination.Fqdn { + case uot.MagicAddress: + request, err := uot.ReadRequest(conn) + if err != nil { + return E.Cause(err, "read UoT request") + } + if request.IsConnect { + r.logger.InfoContext(ctx, "inbound UoT connect connection to ", request.Destination) + } else { + r.logger.InfoContext(ctx, "inbound UoT connection to ", request.Destination) + } + metadata.Domain = metadata.Destination.Fqdn + metadata.Destination = request.Destination + return r.router.RoutePacketConnection(ctx, uot.NewConn(conn, *request), metadata) + case uot.LegacyMagicAddress: + r.logger.InfoContext(ctx, "inbound legacy UoT connection") + metadata.Domain = metadata.Destination.Fqdn + metadata.Destination = M.Socksaddr{Addr: netip.IPv4Unspecified()} + return r.RoutePacketConnection(ctx, uot.NewConn(conn, uot.Request{}), metadata) + } + return r.router.RouteConnection(ctx, conn, metadata) +} + +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + return r.router.RoutePacketConnection(ctx, conn, metadata) +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + switch metadata.Destination.Fqdn { + case uot.MagicAddress: + request, err := uot.ReadRequest(conn) + if err != nil { + err = E.Cause(err, "UoT read request") + r.logger.ErrorContext(ctx, "process connection from ", metadata.Source, ": ", err) + N.CloseOnHandshakeFailure(conn, onClose, err) + return + } + if request.IsConnect { + r.logger.InfoContext(ctx, "inbound UoT connect connection to ", request.Destination) + } else { + r.logger.InfoContext(ctx, "inbound UoT connection to ", request.Destination) + } + metadata.Domain = metadata.Destination.Fqdn + metadata.Destination = request.Destination + r.router.RoutePacketConnectionEx(ctx, uot.NewConn(conn, *request), metadata, onClose) + return + case uot.LegacyMagicAddress: + r.logger.InfoContext(ctx, "inbound legacy UoT connection") + metadata.Domain = metadata.Destination.Fqdn + metadata.Destination = M.Socksaddr{Addr: netip.IPv4Unspecified()} + r.RoutePacketConnectionEx(ctx, uot.NewConn(conn, uot.Request{}), metadata, onClose) + return + } + r.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + r.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go new file mode 100644 index 00000000..29d790e4 --- /dev/null +++ b/common/urltest/urltest.go @@ -0,0 +1,130 @@ +package urltest + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/observable" +) + +var _ adapter.URLTestHistoryStorage = (*HistoryStorage)(nil) + +type HistoryStorage struct { + access sync.RWMutex + delayHistory map[string]*adapter.URLTestHistory + updateHook *observable.Subscriber[struct{}] +} + +func NewHistoryStorage() *HistoryStorage { + return &HistoryStorage{ + delayHistory: make(map[string]*adapter.URLTestHistory), + } +} + +func (s *HistoryStorage) SetHook(hook *observable.Subscriber[struct{}]) { + s.updateHook = hook +} + +func (s *HistoryStorage) LoadURLTestHistory(tag string) *adapter.URLTestHistory { + if s == nil { + return nil + } + s.access.RLock() + defer s.access.RUnlock() + return s.delayHistory[tag] +} + +func (s *HistoryStorage) DeleteURLTestHistory(tag string) { + s.access.Lock() + delete(s.delayHistory, tag) + s.notifyUpdated() + s.access.Unlock() +} + +func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTestHistory) { + s.access.Lock() + s.delayHistory[tag] = history + s.notifyUpdated() + s.access.Unlock() +} + +func (s *HistoryStorage) notifyUpdated() { + updateHook := s.updateHook + if updateHook != nil { + updateHook.Emit(struct{}{}) + } +} + +func (s *HistoryStorage) Close() error { + s.access.Lock() + defer s.access.Unlock() + s.updateHook = nil + return nil +} + +func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) { + if link == "" { + link = "https://www.gstatic.com/generate_204" + } + linkURL, err := url.Parse(link) + if err != nil { + return + } + hostname := linkURL.Hostname() + port := linkURL.Port() + if port == "" { + switch linkURL.Scheme { + case "http": + port = "80" + case "https": + port = "443" + } + } + + start := time.Now() + instance, err := detour.DialContext(ctx, "tcp", M.ParseSocksaddrHostPortStr(hostname, port)) + if err != nil { + return + } + defer instance.Close() + if N.NeedHandshakeForWrite(instance) { + start = time.Now() + } + req, err := http.NewRequest(http.MethodHead, link, nil) + if err != nil { + return + } + client := http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return instance, nil + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: C.TCPTimeout, + } + defer client.CloseIdleConnections() + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return + } + resp.Body.Close() + t = uint16(time.Since(start) / time.Millisecond) + return +} diff --git a/constant/certificate.go b/constant/certificate.go new file mode 100644 index 00000000..05ab1616 --- /dev/null +++ b/constant/certificate.go @@ -0,0 +1,8 @@ +package constant + +const ( + CertificateStoreSystem = "system" + CertificateStoreMozilla = "mozilla" + CertificateStoreChrome = "chrome" + CertificateStoreNone = "none" +) diff --git a/constant/cgo.go b/constant/cgo.go new file mode 100644 index 00000000..6ec4dee7 --- /dev/null +++ b/constant/cgo.go @@ -0,0 +1,5 @@ +//go:build cgo + +package constant + +const CGO_ENABLED = true diff --git a/constant/cgo_disabled.go b/constant/cgo_disabled.go new file mode 100644 index 00000000..51cacad5 --- /dev/null +++ b/constant/cgo_disabled.go @@ -0,0 +1,5 @@ +//go:build !cgo + +package constant + +const CGO_ENABLED = false diff --git a/constant/dhcp.go b/constant/dhcp.go new file mode 100644 index 00000000..bdabd06e --- /dev/null +++ b/constant/dhcp.go @@ -0,0 +1,8 @@ +package constant + +import "time" + +const ( + DHCPTTL = time.Hour + DHCPTimeout = 5 * time.Second +) diff --git a/constant/dns.go b/constant/dns.go new file mode 100644 index 00000000..c7cd0d03 --- /dev/null +++ b/constant/dns.go @@ -0,0 +1,36 @@ +package constant + +const ( + DefaultDNSTTL = 600 +) + +type DomainStrategy = uint8 + +const ( + DomainStrategyAsIS DomainStrategy = iota + DomainStrategyPreferIPv4 + DomainStrategyPreferIPv6 + DomainStrategyIPv4Only + DomainStrategyIPv6Only +) + +const ( + DNSTypeLegacy = "legacy" + DNSTypeUDP = "udp" + DNSTypeTCP = "tcp" + DNSTypeTLS = "tls" + DNSTypeHTTPS = "https" + DNSTypeQUIC = "quic" + DNSTypeHTTP3 = "h3" + DNSTypeLocal = "local" + DNSTypeHosts = "hosts" + DNSTypeFakeIP = "fakeip" + DNSTypeDHCP = "dhcp" + DNSTypeTailscale = "tailscale" +) + +const ( + DNSProviderAliDNS = "alidns" + DNSProviderCloudflare = "cloudflare" + DNSProviderACMEDNS = "acmedns" +) diff --git a/constant/err.go b/constant/err.go new file mode 100644 index 00000000..30845123 --- /dev/null +++ b/constant/err.go @@ -0,0 +1,7 @@ +package constant + +import E "github.com/sagernet/sing/common/exceptions" + +var ErrTLSRequired = E.New("TLS required") + +var ErrQUICNotIncluded = E.New(`QUIC is not included in this build, rebuild with -tags with_quic`) diff --git a/constant/goos/gengoos.go b/constant/goos/gengoos.go new file mode 100644 index 00000000..4b7c6662 --- /dev/null +++ b/constant/goos/gengoos.go @@ -0,0 +1,68 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build ignore + +package main + +import ( + "bytes" + "fmt" + "log" + "os" + "strconv" + "strings" +) + +var gooses []string + +func main() { + data, err := os.ReadFile("../../go/build/syslist.go") + if err != nil { + log.Fatal(err) + } + const goosPrefix = `const goosList = ` + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, goosPrefix) { + text, err := strconv.Unquote(strings.TrimPrefix(line, goosPrefix)) + if err != nil { + log.Fatalf("parsing goosList: %v", err) + } + gooses = strings.Fields(text) + } + } + + for _, target := range gooses { + if target == "nacl" { + continue + } + var tags []string + if target == "linux" { + tags = append(tags, "!android") // must explicitly exclude android for linux + } + if target == "solaris" { + tags = append(tags, "!illumos") // must explicitly exclude illumos for solaris + } + if target == "darwin" { + tags = append(tags, "!ios") // must explicitly exclude ios for darwin + } + tags = append(tags, target) // must explicitly include target for bootstrapping purposes + var buf bytes.Buffer + fmt.Fprintf(&buf, "// Code generated by gengoos.go using 'go generate'. DO NOT EDIT.\n\n") + fmt.Fprintf(&buf, "//go:build %s\n", strings.Join(tags, " && ")) + fmt.Fprintf(&buf, "package goos\n\n") + fmt.Fprintf(&buf, "const GOOS = `%s`\n\n", target) + for _, goos := range gooses { + value := 0 + if goos == target { + value = 1 + } + fmt.Fprintf(&buf, "const Is%s = %d\n", strings.Title(goos), value) + } + err := os.WriteFile("zgoos_"+target+".go", buf.Bytes(), 0o666) + if err != nil { + log.Fatal(err) + } + } +} diff --git a/constant/goos/goos.go b/constant/goos/goos.go new file mode 100644 index 00000000..ebb521fe --- /dev/null +++ b/constant/goos/goos.go @@ -0,0 +1,12 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// package goos contains GOOS-specific constants. +package goos + +// The next line makes 'go generate' write the zgoos*.go files with +// per-OS information, including constants named Is$GOOS for every +// known GOOS. The constant is 1 on the current system, 0 otherwise; +// multiplying by them is useful for defining GOOS-specific constants. +//go:generate go run gengoos.go diff --git a/constant/goos/zgoos_aix.go b/constant/goos/zgoos_aix.go new file mode 100644 index 00000000..ff861550 --- /dev/null +++ b/constant/goos/zgoos_aix.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build aix + +package goos + +const GOOS = `aix` + +const IsAix = 1 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_android.go b/constant/goos/zgoos_android.go new file mode 100644 index 00000000..e8aaa124 --- /dev/null +++ b/constant/goos/zgoos_android.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build android + +package goos + +const GOOS = `android` + +const IsAix = 0 +const IsAndroid = 1 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_darwin.go b/constant/goos/zgoos_darwin.go new file mode 100644 index 00000000..decdd496 --- /dev/null +++ b/constant/goos/zgoos_darwin.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build !ios && darwin + +package goos + +const GOOS = `darwin` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 1 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_dragonfly.go b/constant/goos/zgoos_dragonfly.go new file mode 100644 index 00000000..2224baa2 --- /dev/null +++ b/constant/goos/zgoos_dragonfly.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build dragonfly + +package goos + +const GOOS = `dragonfly` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 1 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_freebsd.go b/constant/goos/zgoos_freebsd.go new file mode 100644 index 00000000..3ee5bf99 --- /dev/null +++ b/constant/goos/zgoos_freebsd.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build freebsd + +package goos + +const GOOS = `freebsd` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 1 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_hurd.go b/constant/goos/zgoos_hurd.go new file mode 100644 index 00000000..8a3d3430 --- /dev/null +++ b/constant/goos/zgoos_hurd.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build hurd + +package goos + +const GOOS = `hurd` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 1 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_illumos.go b/constant/goos/zgoos_illumos.go new file mode 100644 index 00000000..fc1b9a9e --- /dev/null +++ b/constant/goos/zgoos_illumos.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build illumos + +package goos + +const GOOS = `illumos` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 1 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_ios.go b/constant/goos/zgoos_ios.go new file mode 100644 index 00000000..746e769e --- /dev/null +++ b/constant/goos/zgoos_ios.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build ios + +package goos + +const GOOS = `ios` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 1 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_js.go b/constant/goos/zgoos_js.go new file mode 100644 index 00000000..6cf2a5d9 --- /dev/null +++ b/constant/goos/zgoos_js.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build js + +package goos + +const GOOS = `js` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 1 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_linux.go b/constant/goos/zgoos_linux.go new file mode 100644 index 00000000..cb9d6e8a --- /dev/null +++ b/constant/goos/zgoos_linux.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build !android && linux + +package goos + +const GOOS = `linux` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 1 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_netbsd.go b/constant/goos/zgoos_netbsd.go new file mode 100644 index 00000000..8285928d --- /dev/null +++ b/constant/goos/zgoos_netbsd.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build netbsd + +package goos + +const GOOS = `netbsd` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 1 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_openbsd.go b/constant/goos/zgoos_openbsd.go new file mode 100644 index 00000000..3f739a4a --- /dev/null +++ b/constant/goos/zgoos_openbsd.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build openbsd + +package goos + +const GOOS = `openbsd` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 1 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_plan9.go b/constant/goos/zgoos_plan9.go new file mode 100644 index 00000000..d4c1c651 --- /dev/null +++ b/constant/goos/zgoos_plan9.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build plan9 + +package goos + +const GOOS = `plan9` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 1 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_solaris.go b/constant/goos/zgoos_solaris.go new file mode 100644 index 00000000..69e3285a --- /dev/null +++ b/constant/goos/zgoos_solaris.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build !illumos && solaris + +package goos + +const GOOS = `solaris` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 1 +const IsWindows = 0 +const IsZos = 0 diff --git a/constant/goos/zgoos_windows.go b/constant/goos/zgoos_windows.go new file mode 100644 index 00000000..16158be7 --- /dev/null +++ b/constant/goos/zgoos_windows.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build windows + +package goos + +const GOOS = `windows` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 1 +const IsZos = 0 diff --git a/constant/goos/zgoos_zos.go b/constant/goos/zgoos_zos.go new file mode 100644 index 00000000..fb6165c7 --- /dev/null +++ b/constant/goos/zgoos_zos.go @@ -0,0 +1,25 @@ +// Code generated by gengoos.go using 'go generate'. DO NOT EDIT. + +//go:build zos + +package goos + +const GOOS = `zos` + +const IsAix = 0 +const IsAndroid = 0 +const IsDarwin = 0 +const IsDragonfly = 0 +const IsFreebsd = 0 +const IsHurd = 0 +const IsIllumos = 0 +const IsIos = 0 +const IsJs = 0 +const IsLinux = 0 +const IsNacl = 0 +const IsNetbsd = 0 +const IsOpenbsd = 0 +const IsPlan9 = 0 +const IsSolaris = 0 +const IsWindows = 0 +const IsZos = 1 diff --git a/constant/hysteria2.go b/constant/hysteria2.go new file mode 100644 index 00000000..35c0b14f --- /dev/null +++ b/constant/hysteria2.go @@ -0,0 +1,7 @@ +package constant + +const ( + Hysterai2MasqueradeTypeFile = "file" + Hysterai2MasqueradeTypeProxy = "proxy" + Hysterai2MasqueradeTypeString = "string" +) diff --git a/constant/network.go b/constant/network.go new file mode 100644 index 00000000..88a1dd81 --- /dev/null +++ b/constant/network.go @@ -0,0 +1,58 @@ +package constant + +import ( + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" +) + +type InterfaceType uint8 + +const ( + InterfaceTypeWIFI InterfaceType = iota + InterfaceTypeCellular + InterfaceTypeEthernet + InterfaceTypeOther +) + +var ( + interfaceTypeToString = map[InterfaceType]string{ + InterfaceTypeWIFI: "wifi", + InterfaceTypeCellular: "cellular", + InterfaceTypeEthernet: "ethernet", + InterfaceTypeOther: "other", + } + StringToInterfaceType = common.ReverseMap(interfaceTypeToString) +) + +func (t InterfaceType) String() string { + name, loaded := interfaceTypeToString[t] + if !loaded { + return F.ToString(int(t)) + } + return name +} + +type NetworkStrategy uint8 + +const ( + NetworkStrategyDefault NetworkStrategy = iota + NetworkStrategyFallback + NetworkStrategyHybrid +) + +var ( + networkStrategyToString = map[NetworkStrategy]string{ + NetworkStrategyDefault: "default", + NetworkStrategyFallback: "fallback", + NetworkStrategyHybrid: "hybrid", + } + StringToNetworkStrategy = common.ReverseMap(networkStrategyToString) +) + +func (s NetworkStrategy) String() string { + name, loaded := networkStrategyToString[s] + if !loaded { + return F.ToString(int(s)) + } + return name +} diff --git a/constant/os.go b/constant/os.go new file mode 100644 index 00000000..6142767c --- /dev/null +++ b/constant/os.go @@ -0,0 +1,37 @@ +package constant + +import ( + "github.com/sagernet/sing-box/constant/goos" +) + +const IsAndroid = goos.IsAndroid == 1 + +const IsDarwin = goos.IsDarwin == 1 || goos.IsIos == 1 + +const IsDragonfly = goos.IsDragonfly == 1 + +const IsFreebsd = goos.IsFreebsd == 1 + +const IsHurd = goos.IsHurd == 1 + +const IsIllumos = goos.IsIllumos == 1 + +const IsIos = goos.IsIos == 1 + +const IsJs = goos.IsJs == 1 + +const IsLinux = goos.IsLinux == 1 || goos.IsAndroid == 1 + +const IsNacl = goos.IsNacl == 1 + +const IsNetbsd = goos.IsNetbsd == 1 + +const IsOpenbsd = goos.IsOpenbsd == 1 + +const IsPlan9 = goos.IsPlan9 == 1 + +const IsSolaris = goos.IsSolaris == 1 + +const IsWindows = goos.IsWindows == 1 + +const IsZos = goos.IsZos == 1 diff --git a/constant/path.go b/constant/path.go new file mode 100644 index 00000000..ea2aad3e --- /dev/null +++ b/constant/path.go @@ -0,0 +1,41 @@ +package constant + +import ( + "os" + "path/filepath" + + "github.com/sagernet/sing/common/rw" +) + +const dirName = "sing-box" + +var resourcePaths []string + +func FindPath(name string) (string, bool) { + name = os.ExpandEnv(name) + if rw.IsFile(name) { + return name, true + } + for _, dir := range resourcePaths { + if path := filepath.Join(dir, dirName, name); rw.IsFile(path) { + return path, true + } + if path := filepath.Join(dir, name); rw.IsFile(path) { + return path, true + } + } + return name, false +} + +func init() { + resourcePaths = append(resourcePaths, ".") + if home := os.Getenv("HOME"); home != "" { + resourcePaths = append(resourcePaths, home) + } + if userConfigDir, err := os.UserConfigDir(); err == nil { + resourcePaths = append(resourcePaths, userConfigDir) + } + if userCacheDir, err := os.UserCacheDir(); err == nil { + resourcePaths = append(resourcePaths, userCacheDir) + } +} diff --git a/constant/path_unix.go b/constant/path_unix.go new file mode 100644 index 00000000..5d9a201d --- /dev/null +++ b/constant/path_unix.go @@ -0,0 +1,17 @@ +//go:build unix || linux + +package constant + +import ( + "os" +) + +func init() { + resourcePaths = append(resourcePaths, "/etc") + resourcePaths = append(resourcePaths, "/usr/share") + resourcePaths = append(resourcePaths, "/usr/local/etc") + resourcePaths = append(resourcePaths, "/usr/local/share") + if homeDir := os.Getenv("HOME"); homeDir != "" { + resourcePaths = append(resourcePaths, homeDir+"/.local/share") + } +} diff --git a/constant/protocol.go b/constant/protocol.go new file mode 100644 index 00000000..dbe16e51 --- /dev/null +++ b/constant/protocol.go @@ -0,0 +1,22 @@ +package constant + +const ( + ProtocolTLS = "tls" + ProtocolHTTP = "http" + ProtocolQUIC = "quic" + ProtocolDNS = "dns" + ProtocolSTUN = "stun" + ProtocolBitTorrent = "bittorrent" + ProtocolDTLS = "dtls" + ProtocolSSH = "ssh" + ProtocolRDP = "rdp" + ProtocolNTP = "ntp" +) + +const ( + ClientChromium = "chromium" + ClientSafari = "safari" + ClientFirefox = "firefox" + ClientQUICGo = "quic-go" + ClientUnknown = "unknown" +) diff --git a/constant/proxy.go b/constant/proxy.go new file mode 100644 index 00000000..ffec8025 --- /dev/null +++ b/constant/proxy.go @@ -0,0 +1,103 @@ +package constant + +const ( + TypeTun = "tun" + TypeRedirect = "redirect" + TypeTProxy = "tproxy" + TypeDirect = "direct" + TypeBlock = "block" + TypeDNS = "dns" + TypeSOCKS = "socks" + TypeHTTP = "http" + TypeMixed = "mixed" + TypeShadowsocks = "shadowsocks" + TypeVMess = "vmess" + TypeTrojan = "trojan" + TypeNaive = "naive" + TypeWireGuard = "wireguard" + TypeHysteria = "hysteria" + TypeTor = "tor" + TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" + TypeAnyTLS = "anytls" + TypeShadowsocksR = "shadowsocksr" + TypeVLESS = "vless" + TypeTUIC = "tuic" + TypeHysteria2 = "hysteria2" + TypeTailscale = "tailscale" + TypeCloudflared = "cloudflared" + TypeDERP = "derp" + TypeResolved = "resolved" + TypeSSMAPI = "ssm-api" + TypeCCM = "ccm" + TypeOCM = "ocm" + TypeOOMKiller = "oom-killer" + TypeACME = "acme" + TypeCloudflareOriginCA = "cloudflare-origin-ca" +) + +const ( + TypeSelector = "selector" + TypeURLTest = "urltest" +) + +func ProxyDisplayName(proxyType string) string { + switch proxyType { + case TypeTun: + return "TUN" + case TypeRedirect: + return "Redirect" + case TypeTProxy: + return "TProxy" + case TypeDirect: + return "Direct" + case TypeBlock: + return "Block" + case TypeDNS: + return "DNS" + case TypeSOCKS: + return "SOCKS" + case TypeHTTP: + return "HTTP" + case TypeMixed: + return "Mixed" + case TypeShadowsocks: + return "Shadowsocks" + case TypeVMess: + return "VMess" + case TypeTrojan: + return "Trojan" + case TypeNaive: + return "Naive" + case TypeWireGuard: + return "WireGuard" + case TypeHysteria: + return "Hysteria" + case TypeTor: + return "Tor" + case TypeSSH: + return "SSH" + case TypeShadowTLS: + return "ShadowTLS" + case TypeShadowsocksR: + return "ShadowsocksR" + case TypeVLESS: + return "VLESS" + case TypeTUIC: + return "TUIC" + case TypeHysteria2: + return "Hysteria2" + case TypeAnyTLS: + return "AnyTLS" + case TypeTailscale: + return "Tailscale" + case TypeCloudflared: + return "Cloudflared" + case TypeSelector: + return "Selector" + case TypeURLTest: + return "URLTest" + default: + return "Unknown" + } +} diff --git a/constant/quic.go b/constant/quic.go new file mode 100644 index 00000000..50bddf88 --- /dev/null +++ b/constant/quic.go @@ -0,0 +1,5 @@ +//go:build with_quic + +package constant + +const WithQUIC = true diff --git a/constant/quic_stub.go b/constant/quic_stub.go new file mode 100644 index 00000000..95b47fef --- /dev/null +++ b/constant/quic_stub.go @@ -0,0 +1,5 @@ +//go:build !with_quic + +package constant + +const WithQUIC = false diff --git a/constant/rule.go b/constant/rule.go new file mode 100644 index 00000000..efd4a2d3 --- /dev/null +++ b/constant/rule.go @@ -0,0 +1,48 @@ +package constant + +const ( + RuleTypeDefault = "default" + RuleTypeLogical = "logical" +) + +const ( + LogicalTypeAnd = "and" + LogicalTypeOr = "or" +) + +const ( + RuleSetTypeInline = "inline" + RuleSetTypeLocal = "local" + RuleSetTypeRemote = "remote" + RuleSetFormatSource = "source" + RuleSetFormatBinary = "binary" +) + +const ( + RuleSetVersion1 = 1 + iota + RuleSetVersion2 + RuleSetVersion3 + RuleSetVersion4 + RuleSetVersion5 + RuleSetVersionCurrent = RuleSetVersion5 +) + +const ( + RuleActionTypeRoute = "route" + RuleActionTypeRouteOptions = "route-options" + RuleActionTypeEvaluate = "evaluate" + RuleActionTypeRespond = "respond" + RuleActionTypeDirect = "direct" + RuleActionTypeBypass = "bypass" + RuleActionTypeReject = "reject" + RuleActionTypeHijackDNS = "hijack-dns" + RuleActionTypeSniff = "sniff" + RuleActionTypeResolve = "resolve" + RuleActionTypePredefined = "predefined" +) + +const ( + RuleActionRejectMethodDefault = "default" + RuleActionRejectMethodDrop = "drop" + RuleActionRejectMethodReply = "reply" +) diff --git a/constant/speed.go b/constant/speed.go new file mode 100644 index 00000000..7a2ec130 --- /dev/null +++ b/constant/speed.go @@ -0,0 +1,3 @@ +package constant + +const MbpsToBps = 125000 diff --git a/constant/time.go b/constant/time.go new file mode 100644 index 00000000..4249a72d --- /dev/null +++ b/constant/time.go @@ -0,0 +1,3 @@ +package constant + +const TimeLayout = "2006-01-02 15:04:05 -0700" diff --git a/constant/timeout.go b/constant/timeout.go new file mode 100644 index 00000000..e1bc7ccd --- /dev/null +++ b/constant/timeout.go @@ -0,0 +1,35 @@ +package constant + +import "time" + +const ( + TCPKeepAliveInitial = 5 * time.Minute + TCPKeepAliveInterval = 75 * time.Second + TCPConnectTimeout = 5 * time.Second + TCPTimeout = 15 * time.Second + ReadPayloadTimeout = 300 * time.Millisecond + DNSTimeout = 10 * time.Second + UDPTimeout = 5 * time.Minute + DefaultURLTestInterval = 3 * time.Minute + DefaultURLTestIdleTimeout = 30 * time.Minute + StartTimeout = 10 * time.Second + StopTimeout = 5 * time.Second + FatalStopTimeout = 10 * time.Second + FakeIPMetadataSaveInterval = 10 * time.Second + TLSFragmentFallbackDelay = 500 * time.Millisecond +) + +var PortProtocols = map[uint16]string{ + 53: ProtocolDNS, + 123: ProtocolNTP, + 3478: ProtocolSTUN, + 443: ProtocolQUIC, +} + +var ProtocolTimeouts = map[string]time.Duration{ + ProtocolDNS: 10 * time.Second, + ProtocolNTP: 10 * time.Second, + ProtocolSTUN: 10 * time.Second, + ProtocolQUIC: 30 * time.Second, + ProtocolDTLS: 30 * time.Second, +} diff --git a/constant/tls.go b/constant/tls.go new file mode 100644 index 00000000..2d4f64bc --- /dev/null +++ b/constant/tls.go @@ -0,0 +1,3 @@ +package constant + +const ACMETLS1Protocol = "acme-tls/1" diff --git a/constant/v2ray.go b/constant/v2ray.go new file mode 100644 index 00000000..c3089a6c --- /dev/null +++ b/constant/v2ray.go @@ -0,0 +1,9 @@ +package constant + +const ( + V2RayTransportTypeHTTP = "http" + V2RayTransportTypeWebsocket = "ws" + V2RayTransportTypeQUIC = "quic" + V2RayTransportTypeGRPC = "grpc" + V2RayTransportTypeHTTPUpgrade = "httpupgrade" +) diff --git a/constant/version.go b/constant/version.go new file mode 100644 index 00000000..5a816a73 --- /dev/null +++ b/constant/version.go @@ -0,0 +1,3 @@ +package constant + +var Version = "unknown" diff --git a/daemon/deprecated.go b/daemon/deprecated.go new file mode 100644 index 00000000..6f23db99 --- /dev/null +++ b/daemon/deprecated.go @@ -0,0 +1,29 @@ +package daemon + +import ( + "sync" + + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing/common" +) + +var _ deprecated.Manager = (*deprecatedManager)(nil) + +type deprecatedManager struct { + access sync.Mutex + notes []deprecated.Note +} + +func (m *deprecatedManager) ReportDeprecated(feature deprecated.Note) { + m.access.Lock() + defer m.access.Unlock() + m.notes = common.Uniq(append(m.notes, feature)) +} + +func (m *deprecatedManager) Get() []deprecated.Note { + m.access.Lock() + defer m.access.Unlock() + notes := m.notes + m.notes = nil + return notes +} diff --git a/daemon/instance.go b/daemon/instance.go new file mode 100644 index 00000000..9f950c64 --- /dev/null +++ b/daemon/instance.go @@ -0,0 +1,153 @@ +package daemon + +import ( + "bytes" + "context" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" +) + +type Instance struct { + ctx context.Context + cancel context.CancelFunc + instance *box.Box + connectionManager adapter.ConnectionManager + clashServer adapter.ClashServer + cacheFile adapter.CacheFile + pauseManager pause.Manager + urlTestHistoryStorage *urltest.HistoryStorage +} + +func (s *StartedService) CheckConfig(configContent string) error { + options, err := parseConfig(s.ctx, configContent) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(s.ctx) + defer cancel() + instance, err := box.New(box.Options{ + Context: ctx, + Options: options, + }) + if err == nil { + instance.Close() + } + return err +} + +func (s *StartedService) FormatConfig(configContent string) (string, error) { + options, err := parseConfig(s.ctx, configContent) + if err != nil { + return "", err + } + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(options) + if err != nil { + return "", err + } + return buffer.String(), nil +} + +type OverrideOptions struct { + AutoRedirect bool + IncludePackage []string + ExcludePackage []string +} + +func (s *StartedService) newInstance(profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { + ctx := s.ctx + service.MustRegister[deprecated.Manager](ctx, new(deprecatedManager)) + ctx, cancel := context.WithCancel(include.Context(ctx)) + options, err := parseConfig(ctx, profileContent) + if err != nil { + cancel() + return nil, err + } + if overrideOptions != nil { + for _, inbound := range options.Inbounds { + if tunInboundOptions, isTUN := inbound.Options.(*option.TunInboundOptions); isTUN { + tunInboundOptions.AutoRedirect = overrideOptions.AutoRedirect + tunInboundOptions.IncludePackage = append(tunInboundOptions.IncludePackage, overrideOptions.IncludePackage...) + tunInboundOptions.ExcludePackage = append(tunInboundOptions.ExcludePackage, overrideOptions.ExcludePackage...) + break + } + } + } + if s.oomKillerEnabled { + if !common.Any(options.Services, func(it option.Service) bool { + return it.Type == C.TypeOOMKiller + }) { + oomOptions := &option.OOMKillerServiceOptions{ + KillerDisabled: s.oomKillerDisabled, + MemoryLimitOverride: s.oomMemoryLimit, + } + options.Services = append(options.Services, option.Service{ + Type: C.TypeOOMKiller, + Options: oomOptions, + }) + } + } + urlTestHistoryStorage := urltest.NewHistoryStorage() + ctx = service.ContextWithPtr(ctx, urlTestHistoryStorage) + i := &Instance{ + ctx: ctx, + cancel: cancel, + urlTestHistoryStorage: urlTestHistoryStorage, + } + boxInstance, err := box.New(box.Options{ + Context: ctx, + Options: options, + PlatformLogWriter: s, + }) + if err != nil { + cancel() + return nil, err + } + i.instance = boxInstance + i.connectionManager = service.FromContext[adapter.ConnectionManager](ctx) + i.clashServer = service.FromContext[adapter.ClashServer](ctx) + i.pauseManager = service.FromContext[pause.Manager](ctx) + i.cacheFile = service.FromContext[adapter.CacheFile](ctx) + log.SetStdLogger(boxInstance.LogFactory().Logger()) + return i, nil +} + +func (i *Instance) Start() error { + return i.instance.Start() +} + +func (i *Instance) Close() error { + i.cancel() + i.urlTestHistoryStorage.Close() + return i.instance.Close() +} + +func (i *Instance) Box() *box.Box { + return i.instance +} + +func (i *Instance) PauseManager() pause.Manager { + return i.pauseManager +} + +func parseConfig(ctx context.Context, configContent string) (option.Options, error) { + options, err := json.UnmarshalExtendedContext[option.Options](ctx, []byte(configContent)) + if err != nil { + return option.Options{}, E.Cause(err, "decode config") + } + return options, nil +} diff --git a/daemon/platform.go b/daemon/platform.go new file mode 100644 index 00000000..ae954c57 --- /dev/null +++ b/daemon/platform.go @@ -0,0 +1,10 @@ +package daemon + +type PlatformHandler interface { + ServiceStop() error + ServiceReload() error + SystemProxyStatus() (*SystemProxyStatus, error) + SetSystemProxyEnabled(enabled bool) error + TriggerNativeCrash() error + WriteDebugMessage(message string) +} diff --git a/daemon/started_service.go b/daemon/started_service.go new file mode 100644 index 00000000..aa15c7be --- /dev/null +++ b/daemon/started_service.go @@ -0,0 +1,1489 @@ +package daemon + +import ( + "context" + "os" + "runtime" + "sync" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/networkquality" + "github.com/sagernet/sing-box/common/stun" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/clashapi" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/batch" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/memory" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + "github.com/gofrs/uuid/v5" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +var _ StartedServiceServer = (*StartedService)(nil) + +type StartedService struct { + ctx context.Context + // platform adapter.PlatformInterface + handler PlatformHandler + debug bool + logMaxLines int + oomKillerEnabled bool + oomKillerDisabled bool + oomMemoryLimit uint64 + // workingDirectory string + // tempDirectory string + // userID int + // groupID int + // systemProxyEnabled bool + serviceAccess sync.RWMutex + serviceStatus *ServiceStatus + serviceStatusSubscriber *observable.Subscriber[*ServiceStatus] + serviceStatusObserver *observable.Observer[*ServiceStatus] + logAccess sync.RWMutex + logLines list.List[*log.Entry] + logSubscriber *observable.Subscriber[*log.Entry] + logObserver *observable.Observer[*log.Entry] + instance *Instance + startedAt time.Time + urlTestSubscriber *observable.Subscriber[struct{}] + urlTestObserver *observable.Observer[struct{}] + urlTestHistoryStorage *urltest.HistoryStorage + clashModeSubscriber *observable.Subscriber[struct{}] + clashModeObserver *observable.Observer[struct{}] + + connectionEventSubscriber *observable.Subscriber[trafficontrol.ConnectionEvent] + connectionEventObserver *observable.Observer[trafficontrol.ConnectionEvent] +} + +type ServiceOptions struct { + Context context.Context + // Platform adapter.PlatformInterface + Handler PlatformHandler + Debug bool + LogMaxLines int + OOMKillerEnabled bool + OOMKillerDisabled bool + OOMMemoryLimit uint64 + // WorkingDirectory string + // TempDirectory string + // UserID int + // GroupID int + // SystemProxyEnabled bool +} + +func NewStartedService(options ServiceOptions) *StartedService { + s := &StartedService{ + ctx: options.Context, + // platform: options.Platform, + handler: options.Handler, + debug: options.Debug, + logMaxLines: options.LogMaxLines, + oomKillerEnabled: options.OOMKillerEnabled, + oomKillerDisabled: options.OOMKillerDisabled, + oomMemoryLimit: options.OOMMemoryLimit, + // workingDirectory: options.WorkingDirectory, + // tempDirectory: options.TempDirectory, + // userID: options.UserID, + // groupID: options.GroupID, + // systemProxyEnabled: options.SystemProxyEnabled, + serviceStatus: &ServiceStatus{Status: ServiceStatus_IDLE}, + serviceStatusSubscriber: observable.NewSubscriber[*ServiceStatus](4), + logSubscriber: observable.NewSubscriber[*log.Entry](128), + urlTestSubscriber: observable.NewSubscriber[struct{}](1), + urlTestHistoryStorage: urltest.NewHistoryStorage(), + clashModeSubscriber: observable.NewSubscriber[struct{}](1), + connectionEventSubscriber: observable.NewSubscriber[trafficontrol.ConnectionEvent](256), + } + s.serviceStatusObserver = observable.NewObserver(s.serviceStatusSubscriber, 2) + s.logObserver = observable.NewObserver(s.logSubscriber, 64) + s.urlTestObserver = observable.NewObserver(s.urlTestSubscriber, 1) + s.clashModeObserver = observable.NewObserver(s.clashModeSubscriber, 1) + s.connectionEventObserver = observable.NewObserver(s.connectionEventSubscriber, 64) + return s +} + +func (s *StartedService) resetLogs() { + s.logAccess.Lock() + s.logLines = list.List[*log.Entry]{} + s.logAccess.Unlock() + s.logSubscriber.Emit(nil) +} + +func (s *StartedService) updateStatus(newStatus ServiceStatus_Type) { + statusObject := &ServiceStatus{Status: newStatus} + s.serviceStatusSubscriber.Emit(statusObject) + s.serviceStatus = statusObject +} + +func (s *StartedService) updateStatusError(err error) error { + statusObject := &ServiceStatus{Status: ServiceStatus_FATAL, ErrorMessage: err.Error()} + s.serviceStatusSubscriber.Emit(statusObject) + s.serviceStatus = statusObject + s.serviceAccess.Unlock() + return err +} + +func (s *StartedService) waitForStarted(ctx context.Context) error { + s.serviceAccess.RLock() + currentStatus := s.serviceStatus.Status + s.serviceAccess.RUnlock() + + switch currentStatus { + case ServiceStatus_STARTED: + return nil + case ServiceStatus_STARTING: + default: + return os.ErrInvalid + } + + subscription, done, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(subscription) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.ctx.Done(): + return s.ctx.Err() + case status := <-subscription: + switch status.Status { + case ServiceStatus_STARTED: + return nil + case ServiceStatus_FATAL: + return E.New(status.ErrorMessage) + case ServiceStatus_IDLE, ServiceStatus_STOPPING: + return os.ErrInvalid + } + case <-done: + return os.ErrClosed + } + } +} + +func (s *StartedService) StartOrReloadService(profileContent string, options *OverrideOptions) error { + s.serviceAccess.Lock() + switch s.serviceStatus.Status { + case ServiceStatus_IDLE, ServiceStatus_STARTED, ServiceStatus_STARTING, ServiceStatus_FATAL: + default: + s.serviceAccess.Unlock() + return os.ErrInvalid + } + oldInstance := s.instance + if oldInstance != nil { + s.updateStatus(ServiceStatus_STOPPING) + s.serviceAccess.Unlock() + _ = oldInstance.Close() + s.serviceAccess.Lock() + } + s.updateStatus(ServiceStatus_STARTING) + s.resetLogs() + instance, err := s.newInstance(profileContent, options) + if err != nil { + return s.updateStatusError(err) + } + s.instance = instance + instance.urlTestHistoryStorage.SetHook(s.urlTestSubscriber) + if instance.clashServer != nil { + instance.clashServer.SetModeUpdateHook(s.clashModeSubscriber) + instance.clashServer.(*clashapi.Server).TrafficManager().SetEventHook(s.connectionEventSubscriber) + } + s.serviceAccess.Unlock() + err = instance.Start() + s.serviceAccess.Lock() + if s.serviceStatus.Status != ServiceStatus_STARTING { + s.serviceAccess.Unlock() + return nil + } + if err != nil { + return s.updateStatusError(err) + } + s.startedAt = time.Now() + s.updateStatus(ServiceStatus_STARTED) + s.serviceAccess.Unlock() + runtime.GC() + return nil +} + +func (s *StartedService) Close() { + s.serviceStatusSubscriber.Close() + s.logSubscriber.Close() + s.urlTestSubscriber.Close() + s.clashModeSubscriber.Close() + s.connectionEventSubscriber.Close() +} + +func (s *StartedService) CloseService() error { + s.serviceAccess.Lock() + switch s.serviceStatus.Status { + case ServiceStatus_STARTING, ServiceStatus_STARTED: + default: + s.serviceAccess.Unlock() + return os.ErrInvalid + } + s.updateStatus(ServiceStatus_STOPPING) + instance := s.instance + s.instance = nil + if instance != nil { + err := instance.Close() + if err != nil { + return s.updateStatusError(err) + } + } + s.startedAt = time.Time{} + s.updateStatus(ServiceStatus_IDLE) + s.serviceAccess.Unlock() + runtime.GC() + return nil +} + +func (s *StartedService) SetError(err error) { + s.serviceAccess.Lock() + s.updateStatusError(err) + s.WriteMessage(log.LevelError, err.Error()) +} + +func (s *StartedService) StopService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + err := s.handler.ServiceStop() + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) ReloadService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + err := s.handler.ServiceReload() + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) SubscribeServiceStatus(empty *emptypb.Empty, server grpc.ServerStreamingServer[ServiceStatus]) error { + subscription, done, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(subscription) + err = server.Send(s.serviceStatus) + if err != nil { + return err + } + for { + select { + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case newStatus := <-subscription: + err = server.Send(newStatus) + if err != nil { + return err + } + case <-done: + return nil + } + } +} + +func (s *StartedService) SubscribeLog(empty *emptypb.Empty, server grpc.ServerStreamingServer[Log]) error { + var savedLines []*log.Entry + s.logAccess.Lock() + savedLines = make([]*log.Entry, 0, s.logLines.Len()) + for element := s.logLines.Front(); element != nil; element = element.Next() { + savedLines = append(savedLines, element.Value) + } + subscription, done, err := s.logObserver.Subscribe() + s.logAccess.Unlock() + if err != nil { + return err + } + defer s.logObserver.UnSubscribe(subscription) + err = server.Send(&Log{ + Messages: common.Map(savedLines, func(it *log.Entry) *Log_Message { + return &Log_Message{ + Level: LogLevel(it.Level), + Message: it.Message, + } + }), + Reset_: true, + }) + if err != nil { + return err + } + for { + select { + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case message := <-subscription: + var rawMessage Log + if message == nil { + rawMessage.Reset_ = true + } else { + rawMessage.Messages = append(rawMessage.Messages, &Log_Message{ + Level: LogLevel(message.Level), + Message: message.Message, + }) + } + fetch: + for { + select { + case message = <-subscription: + if message == nil { + rawMessage.Messages = nil + rawMessage.Reset_ = true + } else { + rawMessage.Messages = append(rawMessage.Messages, &Log_Message{ + Level: LogLevel(message.Level), + Message: message.Message, + }) + } + default: + break fetch + } + } + err = server.Send(&rawMessage) + if err != nil { + return err + } + case <-done: + return nil + } + } +} + +func (s *StartedService) GetDefaultLogLevel(ctx context.Context, empty *emptypb.Empty) (*DefaultLogLevel, error) { + s.serviceAccess.RLock() + switch s.serviceStatus.Status { + case ServiceStatus_STARTING, ServiceStatus_STARTED: + default: + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + logLevel := s.instance.instance.LogFactory().Level() + s.serviceAccess.RUnlock() + return &DefaultLogLevel{Level: LogLevel(logLevel)}, nil +} + +func (s *StartedService) ClearLogs(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + s.resetLogs() + return &emptypb.Empty{}, nil +} + +func (s *StartedService) SubscribeStatus(request *SubscribeStatusRequest, server grpc.ServerStreamingServer[Status]) error { + interval := time.Duration(request.Interval) + if interval <= 0 { + interval = time.Second // Default to 1 second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + status := s.readStatus() + uploadTotal := status.UplinkTotal + downloadTotal := status.DownlinkTotal + for { + err := server.Send(status) + if err != nil { + return err + } + select { + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case <-ticker.C: + } + status = s.readStatus() + upload := status.UplinkTotal - uploadTotal + download := status.DownlinkTotal - downloadTotal + uploadTotal = status.UplinkTotal + downloadTotal = status.DownlinkTotal + status.Uplink = upload + status.Downlink = download + } +} + +func (s *StartedService) readStatus() *Status { + var status Status + status.Memory = memory.Total() + status.Goroutines = int32(runtime.NumGoroutine()) + s.serviceAccess.RLock() + nowService := s.instance + s.serviceAccess.RUnlock() + if nowService != nil && nowService.connectionManager != nil { + status.ConnectionsOut = int32(nowService.connectionManager.Count()) + } + if nowService != nil { + if clashServer := nowService.clashServer; clashServer != nil { + status.TrafficAvailable = true + trafficManager := clashServer.(*clashapi.Server).TrafficManager() + status.UplinkTotal, status.DownlinkTotal = trafficManager.Total() + status.ConnectionsIn = int32(trafficManager.ConnectionsLen()) + } + } + return &status +} + +func (s *StartedService) SubscribeGroups(empty *emptypb.Empty, server grpc.ServerStreamingServer[Groups]) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + subscription, done, err := s.urlTestObserver.Subscribe() + if err != nil { + return err + } + defer s.urlTestObserver.UnSubscribe(subscription) + for { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return os.ErrInvalid + } + groups := s.readGroups() + s.serviceAccess.RUnlock() + err = server.Send(groups) + if err != nil { + return err + } + select { + case <-subscription: + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case <-done: + return nil + } + } +} + +func (s *StartedService) readGroups() *Groups { + historyStorage := s.instance.urlTestHistoryStorage + boxService := s.instance + outbounds := boxService.instance.Outbound().Outbounds() + var iGroups []adapter.OutboundGroup + for _, it := range outbounds { + if group, isGroup := it.(adapter.OutboundGroup); isGroup { + iGroups = append(iGroups, group) + } + } + var gs Groups + for _, iGroup := range iGroups { + var g Group + g.Tag = iGroup.Tag() + g.Type = iGroup.Type() + _, g.Selectable = iGroup.(*group.Selector) + g.Selected = iGroup.Now() + if boxService.cacheFile != nil { + if isExpand, loaded := boxService.cacheFile.LoadGroupExpand(g.Tag); loaded { + g.IsExpand = isExpand + } + } + + for _, itemTag := range iGroup.All() { + itemOutbound, isLoaded := boxService.instance.Outbound().Outbound(itemTag) + if !isLoaded { + continue + } + + var item GroupItem + item.Tag = itemTag + item.Type = itemOutbound.Type() + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(itemOutbound)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + g.Items = append(g.Items, &item) + } + if len(g.Items) < 2 { + continue + } + gs.Group = append(gs.Group, &g) + } + return &gs +} + +func (s *StartedService) GetClashModeStatus(ctx context.Context, empty *emptypb.Empty) (*ClashModeStatus, error) { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + clashServer := s.instance.clashServer + s.serviceAccess.RUnlock() + if clashServer == nil { + return nil, os.ErrInvalid + } + return &ClashModeStatus{ + ModeList: clashServer.ModeList(), + CurrentMode: clashServer.Mode(), + }, nil +} + +func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.ServerStreamingServer[ClashMode]) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + subscription, done, err := s.clashModeObserver.Subscribe() + if err != nil { + return err + } + defer s.clashModeObserver.UnSubscribe(subscription) + for { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return os.ErrInvalid + } + message := &ClashMode{Mode: s.instance.clashServer.Mode()} + s.serviceAccess.RUnlock() + err = server.Send(message) + if err != nil { + return err + } + select { + case <-subscription: + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case <-done: + return nil + } + } +} + +func (s *StartedService) SetClashMode(ctx context.Context, request *ClashMode) (*emptypb.Empty, error) { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + clashServer := s.instance.clashServer + s.serviceAccess.RUnlock() + clashServer.(*clashapi.Server).SetMode(request.Mode) + return &emptypb.Empty{}, nil +} + +func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) (*emptypb.Empty, error) { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + boxService := s.instance + s.serviceAccess.RUnlock() + groupTag := request.OutboundTag + abstractOutboundGroup, isLoaded := boxService.instance.Outbound().Outbound(groupTag) + if !isLoaded { + return nil, E.New("outbound group not found: ", groupTag) + } + outboundGroup, isOutboundGroup := abstractOutboundGroup.(adapter.OutboundGroup) + if !isOutboundGroup { + return nil, E.New("outbound is not a group: ", groupTag) + } + urlTest, isURLTest := abstractOutboundGroup.(*group.URLTest) + if isURLTest { + go urlTest.CheckOutbounds() + } else { + historyStorage := boxService.urlTestHistoryStorage + + outbounds := common.Filter(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { + itOutbound, _ := boxService.instance.Outbound().Outbound(it) + return itOutbound + }), func(it adapter.Outbound) bool { + if it == nil { + return false + } + _, isGroup := it.(adapter.OutboundGroup) + if isGroup { + return false + } + return true + }) + b, _ := batch.New(boxService.ctx, batch.WithConcurrencyNum[any](10)) + for _, detour := range outbounds { + outboundToTest := detour + outboundTag := outboundToTest.Tag() + b.Go(outboundTag, func() (any, error) { + t, err := urltest.URLTest(boxService.ctx, "", outboundToTest) + if err != nil { + historyStorage.DeleteURLTestHistory(outboundTag) + } else { + historyStorage.StoreURLTestHistory(outboundTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + } + return nil, nil + }) + } + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) SelectOutbound(ctx context.Context, request *SelectOutboundRequest) (*emptypb.Empty, error) { + s.serviceAccess.RLock() + switch s.serviceStatus.Status { + case ServiceStatus_STARTING, ServiceStatus_STARTED: + default: + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + boxService := s.instance.instance + s.serviceAccess.RUnlock() + outboundGroup, isLoaded := boxService.Outbound().Outbound(request.GroupTag) + if !isLoaded { + return nil, E.New("selector not found: ", request.GroupTag) + } + selector, isSelector := outboundGroup.(*group.Selector) + if !isSelector { + return nil, E.New("outbound is not a selector: ", request.GroupTag) + } + if !selector.SelectOutbound(request.OutboundTag) { + return nil, E.New("outbound not found in selector: ", request.OutboundTag) + } + s.urlTestObserver.Emit(struct{}{}) + return &emptypb.Empty{}, nil +} + +func (s *StartedService) SetGroupExpand(ctx context.Context, request *SetGroupExpandRequest) (*emptypb.Empty, error) { + s.serviceAccess.RLock() + switch s.serviceStatus.Status { + case ServiceStatus_STARTING, ServiceStatus_STARTED: + default: + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + boxService := s.instance + s.serviceAccess.RUnlock() + if boxService.cacheFile != nil { + err := boxService.cacheFile.StoreGroupExpand(request.GroupTag, request.IsExpand) + if err != nil { + return nil, err + } + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) GetSystemProxyStatus(ctx context.Context, empty *emptypb.Empty) (*SystemProxyStatus, error) { + return s.handler.SystemProxyStatus() +} + +func (s *StartedService) SetSystemProxyEnabled(ctx context.Context, request *SetSystemProxyEnabledRequest) (*emptypb.Empty, error) { + err := s.handler.SetSystemProxyEnabled(request.Enabled) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) TriggerDebugCrash(ctx context.Context, request *DebugCrashRequest) (*emptypb.Empty, error) { + if !s.debug { + return nil, status.Error(codes.PermissionDenied, "debug crash trigger unavailable") + } + if request == nil { + return nil, status.Error(codes.InvalidArgument, "missing debug crash request") + } + switch request.Type { + case DebugCrashRequest_GO: + time.AfterFunc(200*time.Millisecond, func() { + *(*int)(unsafe.Pointer(uintptr(0))) = 0 + }) + case DebugCrashRequest_NATIVE: + err := s.handler.TriggerNativeCrash() + if err != nil { + return nil, err + } + default: + return nil, status.Error(codes.InvalidArgument, "unknown debug crash type") + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { + instance := s.Instance() + if instance == nil { + return nil, status.Error(codes.FailedPrecondition, "service not started") + } + reporter := service.FromContext[oomkiller.OOMReporter](instance.ctx) + if reporter == nil { + return nil, status.Error(codes.Unavailable, "OOM reporter not available") + } + return &emptypb.Empty{}, reporter.WriteReport(memory.Total()) +} + +func (s *StartedService) SubscribeConnections(request *SubscribeConnectionsRequest, server grpc.ServerStreamingServer[ConnectionEvents]) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + if boxService.clashServer == nil { + return E.New("clash server not available") + } + + trafficManager := boxService.clashServer.(*clashapi.Server).TrafficManager() + + subscription, done, err := s.connectionEventObserver.Subscribe() + if err != nil { + return err + } + defer s.connectionEventObserver.UnSubscribe(subscription) + + connectionSnapshots := make(map[uuid.UUID]connectionSnapshot) + initialEvents := s.buildInitialConnectionState(trafficManager, connectionSnapshots) + err = server.Send(&ConnectionEvents{ + Events: initialEvents, + Reset_: true, + }) + if err != nil { + return err + } + + interval := time.Duration(request.Interval) + if interval <= 0 { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case <-done: + return nil + + case event := <-subscription: + var pendingEvents []*ConnectionEvent + if protoEvent := s.applyConnectionEvent(event, connectionSnapshots); protoEvent != nil { + pendingEvents = append(pendingEvents, protoEvent) + } + drain: + for { + select { + case event = <-subscription: + if protoEvent := s.applyConnectionEvent(event, connectionSnapshots); protoEvent != nil { + pendingEvents = append(pendingEvents, protoEvent) + } + default: + break drain + } + } + if len(pendingEvents) > 0 { + err = server.Send(&ConnectionEvents{Events: pendingEvents}) + if err != nil { + return err + } + } + + case <-ticker.C: + protoEvents := s.buildTrafficUpdates(trafficManager, connectionSnapshots) + if len(protoEvents) == 0 { + continue + } + err = server.Send(&ConnectionEvents{Events: protoEvents}) + if err != nil { + return err + } + } + } +} + +type connectionSnapshot struct { + uplink int64 + downlink int64 + hadTraffic bool +} + +func (s *StartedService) buildInitialConnectionState(manager *trafficontrol.Manager, snapshots map[uuid.UUID]connectionSnapshot) []*ConnectionEvent { + var events []*ConnectionEvent + + for _, metadata := range manager.Connections() { + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_NEW, + Id: metadata.ID.String(), + Connection: buildConnectionProto(metadata), + }) + snapshots[metadata.ID] = connectionSnapshot{ + uplink: metadata.Upload.Load(), + downlink: metadata.Download.Load(), + } + } + + for _, metadata := range manager.ClosedConnections() { + conn := buildConnectionProto(metadata) + conn.ClosedAt = metadata.ClosedAt.UnixMilli() + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_NEW, + Id: metadata.ID.String(), + Connection: conn, + }) + } + + return events +} + +func (s *StartedService) applyConnectionEvent(event trafficontrol.ConnectionEvent, snapshots map[uuid.UUID]connectionSnapshot) *ConnectionEvent { + switch event.Type { + case trafficontrol.ConnectionEventNew: + if _, exists := snapshots[event.ID]; exists { + return nil + } + snapshots[event.ID] = connectionSnapshot{ + uplink: event.Metadata.Upload.Load(), + downlink: event.Metadata.Download.Load(), + } + return &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_NEW, + Id: event.ID.String(), + Connection: buildConnectionProto(event.Metadata), + } + case trafficontrol.ConnectionEventClosed: + delete(snapshots, event.ID) + protoEvent := &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_CLOSED, + Id: event.ID.String(), + } + closedAt := event.ClosedAt + if closedAt.IsZero() && !event.Metadata.ClosedAt.IsZero() { + closedAt = event.Metadata.ClosedAt + } + if closedAt.IsZero() { + closedAt = time.Now() + } + protoEvent.ClosedAt = closedAt.UnixMilli() + if event.Metadata.ID != uuid.Nil { + conn := buildConnectionProto(event.Metadata) + conn.ClosedAt = protoEvent.ClosedAt + protoEvent.Connection = conn + } + return protoEvent + default: + return nil + } +} + +func (s *StartedService) buildTrafficUpdates(manager *trafficontrol.Manager, snapshots map[uuid.UUID]connectionSnapshot) []*ConnectionEvent { + activeConnections := manager.Connections() + activeIndex := make(map[uuid.UUID]*trafficontrol.TrackerMetadata, len(activeConnections)) + var events []*ConnectionEvent + + for _, metadata := range activeConnections { + activeIndex[metadata.ID] = metadata + currentUpload := metadata.Upload.Load() + currentDownload := metadata.Download.Load() + snapshot, exists := snapshots[metadata.ID] + if !exists { + snapshots[metadata.ID] = connectionSnapshot{ + uplink: currentUpload, + downlink: currentDownload, + } + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_NEW, + Id: metadata.ID.String(), + Connection: buildConnectionProto(metadata), + }) + continue + } + uplinkDelta := currentUpload - snapshot.uplink + downlinkDelta := currentDownload - snapshot.downlink + if uplinkDelta < 0 || downlinkDelta < 0 { + if snapshot.hadTraffic { + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_UPDATE, + Id: metadata.ID.String(), + UplinkDelta: 0, + DownlinkDelta: 0, + }) + } + snapshot.uplink = currentUpload + snapshot.downlink = currentDownload + snapshot.hadTraffic = false + snapshots[metadata.ID] = snapshot + continue + } + if uplinkDelta > 0 || downlinkDelta > 0 { + snapshot.uplink = currentUpload + snapshot.downlink = currentDownload + snapshot.hadTraffic = true + snapshots[metadata.ID] = snapshot + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_UPDATE, + Id: metadata.ID.String(), + UplinkDelta: uplinkDelta, + DownlinkDelta: downlinkDelta, + }) + continue + } + if snapshot.hadTraffic { + snapshot.uplink = currentUpload + snapshot.downlink = currentDownload + snapshot.hadTraffic = false + snapshots[metadata.ID] = snapshot + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_UPDATE, + Id: metadata.ID.String(), + UplinkDelta: 0, + DownlinkDelta: 0, + }) + } + } + + var closedIndex map[uuid.UUID]*trafficontrol.TrackerMetadata + for id := range snapshots { + if _, exists := activeIndex[id]; exists { + continue + } + if closedIndex == nil { + closedIndex = make(map[uuid.UUID]*trafficontrol.TrackerMetadata) + for _, metadata := range manager.ClosedConnections() { + closedIndex[metadata.ID] = metadata + } + } + closedAt := time.Now() + var conn *Connection + if metadata, ok := closedIndex[id]; ok { + if !metadata.ClosedAt.IsZero() { + closedAt = metadata.ClosedAt + } + conn = buildConnectionProto(metadata) + conn.ClosedAt = closedAt.UnixMilli() + } + events = append(events, &ConnectionEvent{ + Type: ConnectionEventType_CONNECTION_EVENT_CLOSED, + Id: id.String(), + ClosedAt: closedAt.UnixMilli(), + Connection: conn, + }) + delete(snapshots, id) + } + + return events +} + +func buildConnectionProto(metadata *trafficontrol.TrackerMetadata) *Connection { + var rule string + if metadata.Rule != nil { + rule = metadata.Rule.String() + } + uplinkTotal := metadata.Upload.Load() + downlinkTotal := metadata.Download.Load() + var processInfo *ProcessInfo + if metadata.Metadata.ProcessInfo != nil { + processInfo = &ProcessInfo{ + ProcessId: metadata.Metadata.ProcessInfo.ProcessID, + UserId: metadata.Metadata.ProcessInfo.UserId, + UserName: metadata.Metadata.ProcessInfo.UserName, + ProcessPath: metadata.Metadata.ProcessInfo.ProcessPath, + PackageNames: metadata.Metadata.ProcessInfo.AndroidPackageNames, + } + } + return &Connection{ + Id: metadata.ID.String(), + Inbound: metadata.Metadata.Inbound, + InboundType: metadata.Metadata.InboundType, + IpVersion: int32(metadata.Metadata.IPVersion), + Network: metadata.Metadata.Network, + Source: metadata.Metadata.Source.String(), + Destination: metadata.Metadata.Destination.String(), + Domain: metadata.Metadata.Domain, + Protocol: metadata.Metadata.Protocol, + User: metadata.Metadata.User, + FromOutbound: metadata.Metadata.Outbound, + CreatedAt: metadata.CreatedAt.UnixMilli(), + UplinkTotal: uplinkTotal, + DownlinkTotal: downlinkTotal, + Rule: rule, + Outbound: metadata.Outbound, + OutboundType: metadata.OutboundType, + ChainList: metadata.Chain, + ProcessInfo: processInfo, + } +} + +func (s *StartedService) CloseConnection(ctx context.Context, request *CloseConnectionRequest) (*emptypb.Empty, error) { + s.serviceAccess.RLock() + switch s.serviceStatus.Status { + case ServiceStatus_STARTING, ServiceStatus_STARTED: + default: + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + boxService := s.instance + s.serviceAccess.RUnlock() + targetConn := boxService.clashServer.(*clashapi.Server).TrafficManager().Connection(uuid.FromStringOrNil(request.Id)) + if targetConn != nil { + targetConn.Close() + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) CloseAllConnections(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + s.serviceAccess.RLock() + nowService := s.instance + s.serviceAccess.RUnlock() + if nowService != nil && nowService.connectionManager != nil { + nowService.connectionManager.CloseAll() + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) GetDeprecatedWarnings(ctx context.Context, empty *emptypb.Empty) (*DeprecatedWarnings, error) { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return nil, os.ErrInvalid + } + boxService := s.instance + s.serviceAccess.RUnlock() + notes := service.FromContext[deprecated.Manager](boxService.ctx).(*deprecatedManager).Get() + return &DeprecatedWarnings{ + Warnings: common.Map(notes, func(it deprecated.Note) *DeprecatedWarning { + return &DeprecatedWarning{ + Message: it.Message(), + Impending: it.Impending(), + MigrationLink: it.MigrationLink, + Description: it.Description, + DeprecatedVersion: it.DeprecatedVersion, + ScheduledVersion: it.ScheduledVersion, + } + }), + }, nil +} + +func (s *StartedService) GetStartedAt(ctx context.Context, empty *emptypb.Empty) (*StartedAt, error) { + s.serviceAccess.RLock() + defer s.serviceAccess.RUnlock() + return &StartedAt{StartedAt: s.startedAt.UnixMilli()}, nil +} + +func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.ServerStreamingServer[OutboundList]) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + subscription, done, err := s.urlTestObserver.Subscribe() + if err != nil { + return err + } + defer s.urlTestObserver.UnSubscribe(subscription) + for { + s.serviceAccess.RLock() + if s.serviceStatus.Status != ServiceStatus_STARTED { + s.serviceAccess.RUnlock() + return os.ErrInvalid + } + boxService := s.instance + s.serviceAccess.RUnlock() + historyStorage := boxService.urlTestHistoryStorage + var list OutboundList + for _, ob := range boxService.instance.Outbound().Outbounds() { + item := &GroupItem{ + Tag: ob.Tag(), + Type: ob.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ob)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) + } + for _, ep := range boxService.instance.Endpoint().Endpoints() { + item := &GroupItem{ + Tag: ep.Tag(), + Type: ep.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ep)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) + } + err = server.Send(&list) + if err != nil { + return err + } + select { + case <-subscription: + case <-s.ctx.Done(): + return s.ctx.Err() + case <-server.Context().Done(): + return server.Context().Err() + case <-done: + return nil + } + } +} + +func resolveOutbound(instance *Instance, tag string) (adapter.Outbound, error) { + if tag == "" { + return instance.instance.Outbound().Default(), nil + } + outbound, loaded := instance.instance.Outbound().Outbound(tag) + if !loaded { + return nil, E.New("outbound not found: ", tag) + } + return outbound, nil +} + +func (s *StartedService) StartNetworkQualityTest( + request *NetworkQualityTestRequest, + server grpc.ServerStreamingServer[NetworkQualityTestProgress], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + outbound, err := resolveOutbound(boxService, request.OutboundTag) + if err != nil { + return err + } + + resolvedDialer := dialer.NewResolveDialer(boxService.ctx, outbound, true, "", adapter.DNSQueryOptions{}, 0) + httpClient := networkquality.NewHTTPClient(resolvedDialer) + defer httpClient.CloseIdleConnections() + + measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(resolvedDialer, request.Http3) + if err != nil { + return err + } + + result, nqErr := networkquality.Run(networkquality.Options{ + ConfigURL: request.ConfigURL, + HTTPClient: httpClient, + NewMeasurementClient: measurementClientFactory, + Serial: request.Serial, + MaxRuntime: time.Duration(request.MaxRuntimeSeconds) * time.Second, + Context: server.Context(), + OnProgress: func(p networkquality.Progress) { + _ = server.Send(&NetworkQualityTestProgress{ + Phase: int32(p.Phase), + DownloadCapacity: p.DownloadCapacity, + UploadCapacity: p.UploadCapacity, + DownloadRPM: p.DownloadRPM, + UploadRPM: p.UploadRPM, + IdleLatencyMs: p.IdleLatencyMs, + ElapsedMs: p.ElapsedMs, + DownloadCapacityAccuracy: int32(p.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(p.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(p.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(p.UploadRPMAccuracy), + }) + }, + }) + if nqErr != nil { + return server.Send(&NetworkQualityTestProgress{ + IsFinal: true, + Error: nqErr.Error(), + }) + } + return server.Send(&NetworkQualityTestProgress{ + Phase: int32(networkquality.PhaseDone), + DownloadCapacity: result.DownloadCapacity, + UploadCapacity: result.UploadCapacity, + DownloadRPM: result.DownloadRPM, + UploadRPM: result.UploadRPM, + IdleLatencyMs: result.IdleLatencyMs, + IsFinal: true, + DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(result.UploadRPMAccuracy), + }) +} + +func (s *StartedService) StartSTUNTest( + request *STUNTestRequest, + server grpc.ServerStreamingServer[STUNTestProgress], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + outbound, err := resolveOutbound(boxService, request.OutboundTag) + if err != nil { + return err + } + + resolvedDialer := dialer.NewResolveDialer(boxService.ctx, outbound, true, "", adapter.DNSQueryOptions{}, 0) + + result, stunErr := stun.Run(stun.Options{ + Server: request.Server, + Dialer: resolvedDialer, + Context: server.Context(), + OnProgress: func(p stun.Progress) { + _ = server.Send(&STUNTestProgress{ + Phase: int32(p.Phase), + ExternalAddr: p.ExternalAddr, + LatencyMs: p.LatencyMs, + NatMapping: int32(p.NATMapping), + NatFiltering: int32(p.NATFiltering), + }) + }, + }) + if stunErr != nil { + return server.Send(&STUNTestProgress{ + IsFinal: true, + Error: stunErr.Error(), + }) + } + return server.Send(&STUNTestProgress{ + Phase: int32(stun.PhaseDone), + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NatMapping: int32(result.NATMapping), + NatFiltering: int32(result.NATFiltering), + IsFinal: true, + NatTypeSupported: result.NATTypeSupported, + }) +} + +func (s *StartedService) SubscribeTailscaleStatus( + _ *emptypb.Empty, + server grpc.ServerStreamingServer[TailscaleStatusUpdate], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + if endpointManager == nil { + return status.Error(codes.FailedPrecondition, "endpoint manager not available") + } + + type tailscaleEndpoint struct { + tag string + provider adapter.TailscaleEndpoint + } + var endpoints []tailscaleEndpoint + for _, endpoint := range endpointManager.Endpoints() { + if endpoint.Type() != C.TypeTailscale { + continue + } + provider, loaded := endpoint.(adapter.TailscaleEndpoint) + if !loaded { + continue + } + endpoints = append(endpoints, tailscaleEndpoint{ + tag: endpoint.Tag(), + provider: provider, + }) + } + if len(endpoints) == 0 { + return status.Error(codes.NotFound, "no Tailscale endpoint found") + } + + type taggedStatus struct { + tag string + status *adapter.TailscaleEndpointStatus + } + updates := make(chan taggedStatus, len(endpoints)) + ctx, cancel := context.WithCancel(server.Context()) + defer cancel() + + var waitGroup sync.WaitGroup + for _, endpoint := range endpoints { + waitGroup.Add(1) + go func(tag string, provider adapter.TailscaleEndpoint) { + defer waitGroup.Done() + _ = provider.SubscribeTailscaleStatus(ctx, func(endpointStatus *adapter.TailscaleEndpointStatus) { + select { + case updates <- taggedStatus{tag: tag, status: endpointStatus}: + case <-ctx.Done(): + } + }) + }(endpoint.tag, endpoint.provider) + } + + go func() { + waitGroup.Wait() + close(updates) + }() + + var tags []string + statuses := make(map[string]*adapter.TailscaleEndpointStatus, len(endpoints)) + for update := range updates { + if _, exists := statuses[update.tag]; !exists { + tags = append(tags, update.tag) + } + statuses[update.tag] = update.status + protoEndpoints := make([]*TailscaleEndpointStatus, 0, len(statuses)) + for _, tag := range tags { + protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag])) + } + sendErr := server.Send(&TailscaleStatusUpdate{ + Endpoints: protoEndpoints, + }) + if sendErr != nil { + return sendErr + } + } + return nil +} + +func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus) *TailscaleEndpointStatus { + userGroups := make([]*TailscaleUserGroup, len(s.UserGroups)) + for i, group := range s.UserGroups { + peers := make([]*TailscalePeer, len(group.Peers)) + for j, peer := range group.Peers { + peers[j] = tailscalePeerToProto(peer) + } + userGroups[i] = &TailscaleUserGroup{ + UserID: group.UserID, + LoginName: group.LoginName, + DisplayName: group.DisplayName, + ProfilePicURL: group.ProfilePicURL, + Peers: peers, + } + } + result := &TailscaleEndpointStatus{ + EndpointTag: tag, + BackendState: s.BackendState, + AuthURL: s.AuthURL, + NetworkName: s.NetworkName, + MagicDNSSuffix: s.MagicDNSSuffix, + UserGroups: userGroups, + } + if s.Self != nil { + result.Self = tailscalePeerToProto(s.Self) + } + return result +} + +func tailscalePeerToProto(peer *adapter.TailscalePeer) *TailscalePeer { + return &TailscalePeer{ + HostName: peer.HostName, + DnsName: peer.DNSName, + Os: peer.OS, + TailscaleIPs: peer.TailscaleIPs, + Online: peer.Online, + ExitNode: peer.ExitNode, + ExitNodeOption: peer.ExitNodeOption, + Active: peer.Active, + RxBytes: peer.RxBytes, + TxBytes: peer.TxBytes, + KeyExpiry: peer.KeyExpiry, + } +} + +func (s *StartedService) StartTailscalePing( + request *TailscalePingRequest, + server grpc.ServerStreamingServer[TailscalePingResponse], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + if endpointManager == nil { + return status.Error(codes.FailedPrecondition, "endpoint manager not available") + } + + var provider adapter.TailscaleEndpoint + if request.EndpointTag != "" { + endpoint, loaded := endpointManager.Get(request.EndpointTag) + if !loaded { + return status.Error(codes.NotFound, "endpoint not found: "+request.EndpointTag) + } + if endpoint.Type() != C.TypeTailscale { + return status.Error(codes.InvalidArgument, "endpoint is not Tailscale: "+request.EndpointTag) + } + pingProvider, loaded := endpoint.(adapter.TailscaleEndpoint) + if !loaded { + return status.Error(codes.FailedPrecondition, "endpoint does not support ping") + } + provider = pingProvider + } else { + for _, endpoint := range endpointManager.Endpoints() { + if endpoint.Type() != C.TypeTailscale { + continue + } + pingProvider, loaded := endpoint.(adapter.TailscaleEndpoint) + if loaded { + provider = pingProvider + break + } + } + if provider == nil { + return status.Error(codes.NotFound, "no Tailscale endpoint found") + } + } + + return provider.StartTailscalePing(server.Context(), request.PeerIP, func(result *adapter.TailscalePingResult) { + _ = server.Send(&TailscalePingResponse{ + LatencyMs: result.LatencyMs, + IsDirect: result.IsDirect, + Endpoint: result.Endpoint, + DerpRegionID: result.DERPRegionID, + DerpRegionCode: result.DERPRegionCode, + Error: result.Error, + }) + }) +} + +func (s *StartedService) mustEmbedUnimplementedStartedServiceServer() { +} + +func (s *StartedService) WriteMessage(level log.Level, message string) { + item := &log.Entry{Level: level, Message: message} + s.logAccess.Lock() + s.logLines.PushBack(item) + if s.logLines.Len() > s.logMaxLines { + s.logLines.Remove(s.logLines.Front()) + } + s.logAccess.Unlock() + s.logSubscriber.Emit(item) + if s.debug { + s.handler.WriteDebugMessage(message) + } +} + +func (s *StartedService) Instance() *Instance { + s.serviceAccess.RLock() + defer s.serviceAccess.RUnlock() + return s.instance +} diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go new file mode 100644 index 00000000..28906960 --- /dev/null +++ b/daemon/started_service.pb.go @@ -0,0 +1,3197 @@ +package daemon + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type LogLevel int32 + +const ( + LogLevel_PANIC LogLevel = 0 + LogLevel_FATAL LogLevel = 1 + LogLevel_ERROR LogLevel = 2 + LogLevel_WARN LogLevel = 3 + LogLevel_INFO LogLevel = 4 + LogLevel_DEBUG LogLevel = 5 + LogLevel_TRACE LogLevel = 6 +) + +// Enum value maps for LogLevel. +var ( + LogLevel_name = map[int32]string{ + 0: "PANIC", + 1: "FATAL", + 2: "ERROR", + 3: "WARN", + 4: "INFO", + 5: "DEBUG", + 6: "TRACE", + } + LogLevel_value = map[string]int32{ + "PANIC": 0, + "FATAL": 1, + "ERROR": 2, + "WARN": 3, + "INFO": 4, + "DEBUG": 5, + "TRACE": 6, + } +) + +func (x LogLevel) Enum() *LogLevel { + p := new(LogLevel) + *p = x + return p +} + +func (x LogLevel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (LogLevel) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_started_service_proto_enumTypes[0].Descriptor() +} + +func (LogLevel) Type() protoreflect.EnumType { + return &file_daemon_started_service_proto_enumTypes[0] +} + +func (x LogLevel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use LogLevel.Descriptor instead. +func (LogLevel) EnumDescriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{0} +} + +type ConnectionEventType int32 + +const ( + ConnectionEventType_CONNECTION_EVENT_NEW ConnectionEventType = 0 + ConnectionEventType_CONNECTION_EVENT_UPDATE ConnectionEventType = 1 + ConnectionEventType_CONNECTION_EVENT_CLOSED ConnectionEventType = 2 +) + +// Enum value maps for ConnectionEventType. +var ( + ConnectionEventType_name = map[int32]string{ + 0: "CONNECTION_EVENT_NEW", + 1: "CONNECTION_EVENT_UPDATE", + 2: "CONNECTION_EVENT_CLOSED", + } + ConnectionEventType_value = map[string]int32{ + "CONNECTION_EVENT_NEW": 0, + "CONNECTION_EVENT_UPDATE": 1, + "CONNECTION_EVENT_CLOSED": 2, + } +) + +func (x ConnectionEventType) Enum() *ConnectionEventType { + p := new(ConnectionEventType) + *p = x + return p +} + +func (x ConnectionEventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ConnectionEventType) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_started_service_proto_enumTypes[1].Descriptor() +} + +func (ConnectionEventType) Type() protoreflect.EnumType { + return &file_daemon_started_service_proto_enumTypes[1] +} + +func (x ConnectionEventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ConnectionEventType.Descriptor instead. +func (ConnectionEventType) EnumDescriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{1} +} + +type ServiceStatus_Type int32 + +const ( + ServiceStatus_IDLE ServiceStatus_Type = 0 + ServiceStatus_STARTING ServiceStatus_Type = 1 + ServiceStatus_STARTED ServiceStatus_Type = 2 + ServiceStatus_STOPPING ServiceStatus_Type = 3 + ServiceStatus_FATAL ServiceStatus_Type = 4 +) + +// Enum value maps for ServiceStatus_Type. +var ( + ServiceStatus_Type_name = map[int32]string{ + 0: "IDLE", + 1: "STARTING", + 2: "STARTED", + 3: "STOPPING", + 4: "FATAL", + } + ServiceStatus_Type_value = map[string]int32{ + "IDLE": 0, + "STARTING": 1, + "STARTED": 2, + "STOPPING": 3, + "FATAL": 4, + } +) + +func (x ServiceStatus_Type) Enum() *ServiceStatus_Type { + p := new(ServiceStatus_Type) + *p = x + return p +} + +func (x ServiceStatus_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ServiceStatus_Type) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_started_service_proto_enumTypes[2].Descriptor() +} + +func (ServiceStatus_Type) Type() protoreflect.EnumType { + return &file_daemon_started_service_proto_enumTypes[2] +} + +func (x ServiceStatus_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ServiceStatus_Type.Descriptor instead. +func (ServiceStatus_Type) EnumDescriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{0, 0} +} + +type DebugCrashRequest_Type int32 + +const ( + DebugCrashRequest_GO DebugCrashRequest_Type = 0 + DebugCrashRequest_NATIVE DebugCrashRequest_Type = 1 +) + +// Enum value maps for DebugCrashRequest_Type. +var ( + DebugCrashRequest_Type_name = map[int32]string{ + 0: "GO", + 1: "NATIVE", + } + DebugCrashRequest_Type_value = map[string]int32{ + "GO": 0, + "NATIVE": 1, + } +) + +func (x DebugCrashRequest_Type) Enum() *DebugCrashRequest_Type { + p := new(DebugCrashRequest_Type) + *p = x + return p +} + +func (x DebugCrashRequest_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DebugCrashRequest_Type) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_started_service_proto_enumTypes[3].Descriptor() +} + +func (DebugCrashRequest_Type) Type() protoreflect.EnumType { + return &file_daemon_started_service_proto_enumTypes[3] +} + +func (x DebugCrashRequest_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DebugCrashRequest_Type.Descriptor instead. +func (DebugCrashRequest_Type) EnumDescriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{16, 0} +} + +type ServiceStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status ServiceStatus_Type `protobuf:"varint,1,opt,name=status,proto3,enum=daemon.ServiceStatus_Type" json:"status,omitempty"` + ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceStatus) Reset() { + *x = ServiceStatus{} + mi := &file_daemon_started_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceStatus) ProtoMessage() {} + +func (x *ServiceStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceStatus.ProtoReflect.Descriptor instead. +func (*ServiceStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{0} +} + +func (x *ServiceStatus) GetStatus() ServiceStatus_Type { + if x != nil { + return x.Status + } + return ServiceStatus_IDLE +} + +func (x *ServiceStatus) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type ReloadServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NewProfileContent string `protobuf:"bytes,1,opt,name=newProfileContent,proto3" json:"newProfileContent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReloadServiceRequest) Reset() { + *x = ReloadServiceRequest{} + mi := &file_daemon_started_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReloadServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReloadServiceRequest) ProtoMessage() {} + +func (x *ReloadServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReloadServiceRequest.ProtoReflect.Descriptor instead. +func (*ReloadServiceRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{1} +} + +func (x *ReloadServiceRequest) GetNewProfileContent() string { + if x != nil { + return x.NewProfileContent + } + return "" +} + +type SubscribeStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interval int64 `protobuf:"varint,1,opt,name=interval,proto3" json:"interval,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeStatusRequest) Reset() { + *x = SubscribeStatusRequest{} + mi := &file_daemon_started_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeStatusRequest) ProtoMessage() {} + +func (x *SubscribeStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeStatusRequest.ProtoReflect.Descriptor instead. +func (*SubscribeStatusRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{2} +} + +func (x *SubscribeStatusRequest) GetInterval() int64 { + if x != nil { + return x.Interval + } + return 0 +} + +type Log struct { + state protoimpl.MessageState `protogen:"open.v1"` + Messages []*Log_Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` + Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Log) Reset() { + *x = Log{} + mi := &file_daemon_started_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Log) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Log) ProtoMessage() {} + +func (x *Log) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Log.ProtoReflect.Descriptor instead. +func (*Log) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{3} +} + +func (x *Log) GetMessages() []*Log_Message { + if x != nil { + return x.Messages + } + return nil +} + +func (x *Log) GetReset_() bool { + if x != nil { + return x.Reset_ + } + return false +} + +type DefaultLogLevel struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level LogLevel `protobuf:"varint,1,opt,name=level,proto3,enum=daemon.LogLevel" json:"level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DefaultLogLevel) Reset() { + *x = DefaultLogLevel{} + mi := &file_daemon_started_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DefaultLogLevel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DefaultLogLevel) ProtoMessage() {} + +func (x *DefaultLogLevel) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DefaultLogLevel.ProtoReflect.Descriptor instead. +func (*DefaultLogLevel) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{4} +} + +func (x *DefaultLogLevel) GetLevel() LogLevel { + if x != nil { + return x.Level + } + return LogLevel_PANIC +} + +type Status struct { + state protoimpl.MessageState `protogen:"open.v1"` + Memory uint64 `protobuf:"varint,1,opt,name=memory,proto3" json:"memory,omitempty"` + Goroutines int32 `protobuf:"varint,2,opt,name=goroutines,proto3" json:"goroutines,omitempty"` + ConnectionsIn int32 `protobuf:"varint,3,opt,name=connectionsIn,proto3" json:"connectionsIn,omitempty"` + ConnectionsOut int32 `protobuf:"varint,4,opt,name=connectionsOut,proto3" json:"connectionsOut,omitempty"` + TrafficAvailable bool `protobuf:"varint,5,opt,name=trafficAvailable,proto3" json:"trafficAvailable,omitempty"` + Uplink int64 `protobuf:"varint,6,opt,name=uplink,proto3" json:"uplink,omitempty"` + Downlink int64 `protobuf:"varint,7,opt,name=downlink,proto3" json:"downlink,omitempty"` + UplinkTotal int64 `protobuf:"varint,8,opt,name=uplinkTotal,proto3" json:"uplinkTotal,omitempty"` + DownlinkTotal int64 `protobuf:"varint,9,opt,name=downlinkTotal,proto3" json:"downlinkTotal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Status) Reset() { + *x = Status{} + mi := &file_daemon_started_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Status) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Status) ProtoMessage() {} + +func (x *Status) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Status.ProtoReflect.Descriptor instead. +func (*Status) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{5} +} + +func (x *Status) GetMemory() uint64 { + if x != nil { + return x.Memory + } + return 0 +} + +func (x *Status) GetGoroutines() int32 { + if x != nil { + return x.Goroutines + } + return 0 +} + +func (x *Status) GetConnectionsIn() int32 { + if x != nil { + return x.ConnectionsIn + } + return 0 +} + +func (x *Status) GetConnectionsOut() int32 { + if x != nil { + return x.ConnectionsOut + } + return 0 +} + +func (x *Status) GetTrafficAvailable() bool { + if x != nil { + return x.TrafficAvailable + } + return false +} + +func (x *Status) GetUplink() int64 { + if x != nil { + return x.Uplink + } + return 0 +} + +func (x *Status) GetDownlink() int64 { + if x != nil { + return x.Downlink + } + return 0 +} + +func (x *Status) GetUplinkTotal() int64 { + if x != nil { + return x.UplinkTotal + } + return 0 +} + +func (x *Status) GetDownlinkTotal() int64 { + if x != nil { + return x.DownlinkTotal + } + return 0 +} + +type Groups struct { + state protoimpl.MessageState `protogen:"open.v1"` + Group []*Group `protobuf:"bytes,1,rep,name=group,proto3" json:"group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Groups) Reset() { + *x = Groups{} + mi := &file_daemon_started_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Groups) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Groups) ProtoMessage() {} + +func (x *Groups) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Groups.ProtoReflect.Descriptor instead. +func (*Groups) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{6} +} + +func (x *Groups) GetGroup() []*Group { + if x != nil { + return x.Group + } + return nil +} + +type Group struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Selectable bool `protobuf:"varint,3,opt,name=selectable,proto3" json:"selectable,omitempty"` + Selected string `protobuf:"bytes,4,opt,name=selected,proto3" json:"selected,omitempty"` + IsExpand bool `protobuf:"varint,5,opt,name=isExpand,proto3" json:"isExpand,omitempty"` + Items []*GroupItem `protobuf:"bytes,6,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Group) Reset() { + *x = Group{} + mi := &file_daemon_started_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Group) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Group) ProtoMessage() {} + +func (x *Group) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Group.ProtoReflect.Descriptor instead. +func (*Group) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{7} +} + +func (x *Group) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *Group) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Group) GetSelectable() bool { + if x != nil { + return x.Selectable + } + return false +} + +func (x *Group) GetSelected() string { + if x != nil { + return x.Selected + } + return "" +} + +func (x *Group) GetIsExpand() bool { + if x != nil { + return x.IsExpand + } + return false +} + +func (x *Group) GetItems() []*GroupItem { + if x != nil { + return x.Items + } + return nil +} + +type GroupItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + UrlTestTime int64 `protobuf:"varint,3,opt,name=urlTestTime,proto3" json:"urlTestTime,omitempty"` + UrlTestDelay int32 `protobuf:"varint,4,opt,name=urlTestDelay,proto3" json:"urlTestDelay,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GroupItem) Reset() { + *x = GroupItem{} + mi := &file_daemon_started_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GroupItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GroupItem) ProtoMessage() {} + +func (x *GroupItem) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GroupItem.ProtoReflect.Descriptor instead. +func (*GroupItem) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{8} +} + +func (x *GroupItem) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *GroupItem) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *GroupItem) GetUrlTestTime() int64 { + if x != nil { + return x.UrlTestTime + } + return 0 +} + +func (x *GroupItem) GetUrlTestDelay() int32 { + if x != nil { + return x.UrlTestDelay + } + return 0 +} + +type URLTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OutboundTag string `protobuf:"bytes,1,opt,name=outboundTag,proto3" json:"outboundTag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *URLTestRequest) Reset() { + *x = URLTestRequest{} + mi := &file_daemon_started_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *URLTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*URLTestRequest) ProtoMessage() {} + +func (x *URLTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use URLTestRequest.ProtoReflect.Descriptor instead. +func (*URLTestRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{9} +} + +func (x *URLTestRequest) GetOutboundTag() string { + if x != nil { + return x.OutboundTag + } + return "" +} + +type SelectOutboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupTag string `protobuf:"bytes,1,opt,name=groupTag,proto3" json:"groupTag,omitempty"` + OutboundTag string `protobuf:"bytes,2,opt,name=outboundTag,proto3" json:"outboundTag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SelectOutboundRequest) Reset() { + *x = SelectOutboundRequest{} + mi := &file_daemon_started_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SelectOutboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SelectOutboundRequest) ProtoMessage() {} + +func (x *SelectOutboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SelectOutboundRequest.ProtoReflect.Descriptor instead. +func (*SelectOutboundRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{10} +} + +func (x *SelectOutboundRequest) GetGroupTag() string { + if x != nil { + return x.GroupTag + } + return "" +} + +func (x *SelectOutboundRequest) GetOutboundTag() string { + if x != nil { + return x.OutboundTag + } + return "" +} + +type SetGroupExpandRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupTag string `protobuf:"bytes,1,opt,name=groupTag,proto3" json:"groupTag,omitempty"` + IsExpand bool `protobuf:"varint,2,opt,name=isExpand,proto3" json:"isExpand,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetGroupExpandRequest) Reset() { + *x = SetGroupExpandRequest{} + mi := &file_daemon_started_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetGroupExpandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetGroupExpandRequest) ProtoMessage() {} + +func (x *SetGroupExpandRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetGroupExpandRequest.ProtoReflect.Descriptor instead. +func (*SetGroupExpandRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{11} +} + +func (x *SetGroupExpandRequest) GetGroupTag() string { + if x != nil { + return x.GroupTag + } + return "" +} + +func (x *SetGroupExpandRequest) GetIsExpand() bool { + if x != nil { + return x.IsExpand + } + return false +} + +type ClashMode struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mode string `protobuf:"bytes,3,opt,name=mode,proto3" json:"mode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClashMode) Reset() { + *x = ClashMode{} + mi := &file_daemon_started_service_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClashMode) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClashMode) ProtoMessage() {} + +func (x *ClashMode) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClashMode.ProtoReflect.Descriptor instead. +func (*ClashMode) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{12} +} + +func (x *ClashMode) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +type ClashModeStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + ModeList []string `protobuf:"bytes,1,rep,name=modeList,proto3" json:"modeList,omitempty"` + CurrentMode string `protobuf:"bytes,2,opt,name=currentMode,proto3" json:"currentMode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClashModeStatus) Reset() { + *x = ClashModeStatus{} + mi := &file_daemon_started_service_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClashModeStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClashModeStatus) ProtoMessage() {} + +func (x *ClashModeStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClashModeStatus.ProtoReflect.Descriptor instead. +func (*ClashModeStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{13} +} + +func (x *ClashModeStatus) GetModeList() []string { + if x != nil { + return x.ModeList + } + return nil +} + +func (x *ClashModeStatus) GetCurrentMode() string { + if x != nil { + return x.CurrentMode + } + return "" +} + +type SystemProxyStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + Available bool `protobuf:"varint,1,opt,name=available,proto3" json:"available,omitempty"` + Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SystemProxyStatus) Reset() { + *x = SystemProxyStatus{} + mi := &file_daemon_started_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SystemProxyStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SystemProxyStatus) ProtoMessage() {} + +func (x *SystemProxyStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SystemProxyStatus.ProtoReflect.Descriptor instead. +func (*SystemProxyStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{14} +} + +func (x *SystemProxyStatus) GetAvailable() bool { + if x != nil { + return x.Available + } + return false +} + +func (x *SystemProxyStatus) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +type SetSystemProxyEnabledRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSystemProxyEnabledRequest) Reset() { + *x = SetSystemProxyEnabledRequest{} + mi := &file_daemon_started_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSystemProxyEnabledRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSystemProxyEnabledRequest) ProtoMessage() {} + +func (x *SetSystemProxyEnabledRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSystemProxyEnabledRequest.ProtoReflect.Descriptor instead. +func (*SetSystemProxyEnabledRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{15} +} + +func (x *SetSystemProxyEnabledRequest) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +type DebugCrashRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type DebugCrashRequest_Type `protobuf:"varint,1,opt,name=type,proto3,enum=daemon.DebugCrashRequest_Type" json:"type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DebugCrashRequest) Reset() { + *x = DebugCrashRequest{} + mi := &file_daemon_started_service_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DebugCrashRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DebugCrashRequest) ProtoMessage() {} + +func (x *DebugCrashRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DebugCrashRequest.ProtoReflect.Descriptor instead. +func (*DebugCrashRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{16} +} + +func (x *DebugCrashRequest) GetType() DebugCrashRequest_Type { + if x != nil { + return x.Type + } + return DebugCrashRequest_GO +} + +type SubscribeConnectionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interval int64 `protobuf:"varint,1,opt,name=interval,proto3" json:"interval,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeConnectionsRequest) Reset() { + *x = SubscribeConnectionsRequest{} + mi := &file_daemon_started_service_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeConnectionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeConnectionsRequest) ProtoMessage() {} + +func (x *SubscribeConnectionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeConnectionsRequest.ProtoReflect.Descriptor instead. +func (*SubscribeConnectionsRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{17} +} + +func (x *SubscribeConnectionsRequest) GetInterval() int64 { + if x != nil { + return x.Interval + } + return 0 +} + +type ConnectionEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type ConnectionEventType `protobuf:"varint,1,opt,name=type,proto3,enum=daemon.ConnectionEventType" json:"type,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + Connection *Connection `protobuf:"bytes,3,opt,name=connection,proto3" json:"connection,omitempty"` + UplinkDelta int64 `protobuf:"varint,4,opt,name=uplinkDelta,proto3" json:"uplinkDelta,omitempty"` + DownlinkDelta int64 `protobuf:"varint,5,opt,name=downlinkDelta,proto3" json:"downlinkDelta,omitempty"` + ClosedAt int64 `protobuf:"varint,6,opt,name=closedAt,proto3" json:"closedAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectionEvent) Reset() { + *x = ConnectionEvent{} + mi := &file_daemon_started_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectionEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionEvent) ProtoMessage() {} + +func (x *ConnectionEvent) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionEvent.ProtoReflect.Descriptor instead. +func (*ConnectionEvent) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{18} +} + +func (x *ConnectionEvent) GetType() ConnectionEventType { + if x != nil { + return x.Type + } + return ConnectionEventType_CONNECTION_EVENT_NEW +} + +func (x *ConnectionEvent) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ConnectionEvent) GetConnection() *Connection { + if x != nil { + return x.Connection + } + return nil +} + +func (x *ConnectionEvent) GetUplinkDelta() int64 { + if x != nil { + return x.UplinkDelta + } + return 0 +} + +func (x *ConnectionEvent) GetDownlinkDelta() int64 { + if x != nil { + return x.DownlinkDelta + } + return 0 +} + +func (x *ConnectionEvent) GetClosedAt() int64 { + if x != nil { + return x.ClosedAt + } + return 0 +} + +type ConnectionEvents struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*ConnectionEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectionEvents) Reset() { + *x = ConnectionEvents{} + mi := &file_daemon_started_service_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectionEvents) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionEvents) ProtoMessage() {} + +func (x *ConnectionEvents) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionEvents.ProtoReflect.Descriptor instead. +func (*ConnectionEvents) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{19} +} + +func (x *ConnectionEvents) GetEvents() []*ConnectionEvent { + if x != nil { + return x.Events + } + return nil +} + +func (x *ConnectionEvents) GetReset_() bool { + if x != nil { + return x.Reset_ + } + return false +} + +type Connection struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Inbound string `protobuf:"bytes,2,opt,name=inbound,proto3" json:"inbound,omitempty"` + InboundType string `protobuf:"bytes,3,opt,name=inboundType,proto3" json:"inboundType,omitempty"` + IpVersion int32 `protobuf:"varint,4,opt,name=ipVersion,proto3" json:"ipVersion,omitempty"` + Network string `protobuf:"bytes,5,opt,name=network,proto3" json:"network,omitempty"` + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` + Destination string `protobuf:"bytes,7,opt,name=destination,proto3" json:"destination,omitempty"` + Domain string `protobuf:"bytes,8,opt,name=domain,proto3" json:"domain,omitempty"` + Protocol string `protobuf:"bytes,9,opt,name=protocol,proto3" json:"protocol,omitempty"` + User string `protobuf:"bytes,10,opt,name=user,proto3" json:"user,omitempty"` + FromOutbound string `protobuf:"bytes,11,opt,name=fromOutbound,proto3" json:"fromOutbound,omitempty"` + CreatedAt int64 `protobuf:"varint,12,opt,name=createdAt,proto3" json:"createdAt,omitempty"` + ClosedAt int64 `protobuf:"varint,13,opt,name=closedAt,proto3" json:"closedAt,omitempty"` + Uplink int64 `protobuf:"varint,14,opt,name=uplink,proto3" json:"uplink,omitempty"` + Downlink int64 `protobuf:"varint,15,opt,name=downlink,proto3" json:"downlink,omitempty"` + UplinkTotal int64 `protobuf:"varint,16,opt,name=uplinkTotal,proto3" json:"uplinkTotal,omitempty"` + DownlinkTotal int64 `protobuf:"varint,17,opt,name=downlinkTotal,proto3" json:"downlinkTotal,omitempty"` + Rule string `protobuf:"bytes,18,opt,name=rule,proto3" json:"rule,omitempty"` + Outbound string `protobuf:"bytes,19,opt,name=outbound,proto3" json:"outbound,omitempty"` + OutboundType string `protobuf:"bytes,20,opt,name=outboundType,proto3" json:"outboundType,omitempty"` + ChainList []string `protobuf:"bytes,21,rep,name=chainList,proto3" json:"chainList,omitempty"` + ProcessInfo *ProcessInfo `protobuf:"bytes,22,opt,name=processInfo,proto3" json:"processInfo,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Connection) Reset() { + *x = Connection{} + mi := &file_daemon_started_service_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Connection) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Connection) ProtoMessage() {} + +func (x *Connection) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Connection.ProtoReflect.Descriptor instead. +func (*Connection) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{20} +} + +func (x *Connection) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Connection) GetInbound() string { + if x != nil { + return x.Inbound + } + return "" +} + +func (x *Connection) GetInboundType() string { + if x != nil { + return x.InboundType + } + return "" +} + +func (x *Connection) GetIpVersion() int32 { + if x != nil { + return x.IpVersion + } + return 0 +} + +func (x *Connection) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *Connection) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *Connection) GetDestination() string { + if x != nil { + return x.Destination + } + return "" +} + +func (x *Connection) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *Connection) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *Connection) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *Connection) GetFromOutbound() string { + if x != nil { + return x.FromOutbound + } + return "" +} + +func (x *Connection) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *Connection) GetClosedAt() int64 { + if x != nil { + return x.ClosedAt + } + return 0 +} + +func (x *Connection) GetUplink() int64 { + if x != nil { + return x.Uplink + } + return 0 +} + +func (x *Connection) GetDownlink() int64 { + if x != nil { + return x.Downlink + } + return 0 +} + +func (x *Connection) GetUplinkTotal() int64 { + if x != nil { + return x.UplinkTotal + } + return 0 +} + +func (x *Connection) GetDownlinkTotal() int64 { + if x != nil { + return x.DownlinkTotal + } + return 0 +} + +func (x *Connection) GetRule() string { + if x != nil { + return x.Rule + } + return "" +} + +func (x *Connection) GetOutbound() string { + if x != nil { + return x.Outbound + } + return "" +} + +func (x *Connection) GetOutboundType() string { + if x != nil { + return x.OutboundType + } + return "" +} + +func (x *Connection) GetChainList() []string { + if x != nil { + return x.ChainList + } + return nil +} + +func (x *Connection) GetProcessInfo() *ProcessInfo { + if x != nil { + return x.ProcessInfo + } + return nil +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProcessId uint32 `protobuf:"varint,1,opt,name=processId,proto3" json:"processId,omitempty"` + UserId int32 `protobuf:"varint,2,opt,name=userId,proto3" json:"userId,omitempty"` + UserName string `protobuf:"bytes,3,opt,name=userName,proto3" json:"userName,omitempty"` + ProcessPath string `protobuf:"bytes,4,opt,name=processPath,proto3" json:"processPath,omitempty"` + PackageNames []string `protobuf:"bytes,5,rep,name=packageNames,proto3" json:"packageNames,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_daemon_started_service_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessInfo) GetProcessId() uint32 { + if x != nil { + return x.ProcessId + } + return 0 +} + +func (x *ProcessInfo) GetUserId() int32 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *ProcessInfo) GetUserName() string { + if x != nil { + return x.UserName + } + return "" +} + +func (x *ProcessInfo) GetProcessPath() string { + if x != nil { + return x.ProcessPath + } + return "" +} + +func (x *ProcessInfo) GetPackageNames() []string { + if x != nil { + return x.PackageNames + } + return nil +} + +type CloseConnectionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseConnectionRequest) Reset() { + *x = CloseConnectionRequest{} + mi := &file_daemon_started_service_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseConnectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseConnectionRequest) ProtoMessage() {} + +func (x *CloseConnectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseConnectionRequest.ProtoReflect.Descriptor instead. +func (*CloseConnectionRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{22} +} + +func (x *CloseConnectionRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type DeprecatedWarnings struct { + state protoimpl.MessageState `protogen:"open.v1"` + Warnings []*DeprecatedWarning `protobuf:"bytes,1,rep,name=warnings,proto3" json:"warnings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeprecatedWarnings) Reset() { + *x = DeprecatedWarnings{} + mi := &file_daemon_started_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeprecatedWarnings) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeprecatedWarnings) ProtoMessage() {} + +func (x *DeprecatedWarnings) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeprecatedWarnings.ProtoReflect.Descriptor instead. +func (*DeprecatedWarnings) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{23} +} + +func (x *DeprecatedWarnings) GetWarnings() []*DeprecatedWarning { + if x != nil { + return x.Warnings + } + return nil +} + +type DeprecatedWarning struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Impending bool `protobuf:"varint,2,opt,name=impending,proto3" json:"impending,omitempty"` + MigrationLink string `protobuf:"bytes,3,opt,name=migrationLink,proto3" json:"migrationLink,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + DeprecatedVersion string `protobuf:"bytes,5,opt,name=deprecatedVersion,proto3" json:"deprecatedVersion,omitempty"` + ScheduledVersion string `protobuf:"bytes,6,opt,name=scheduledVersion,proto3" json:"scheduledVersion,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeprecatedWarning) Reset() { + *x = DeprecatedWarning{} + mi := &file_daemon_started_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeprecatedWarning) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeprecatedWarning) ProtoMessage() {} + +func (x *DeprecatedWarning) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeprecatedWarning.ProtoReflect.Descriptor instead. +func (*DeprecatedWarning) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{24} +} + +func (x *DeprecatedWarning) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *DeprecatedWarning) GetImpending() bool { + if x != nil { + return x.Impending + } + return false +} + +func (x *DeprecatedWarning) GetMigrationLink() string { + if x != nil { + return x.MigrationLink + } + return "" +} + +func (x *DeprecatedWarning) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *DeprecatedWarning) GetDeprecatedVersion() string { + if x != nil { + return x.DeprecatedVersion + } + return "" +} + +func (x *DeprecatedWarning) GetScheduledVersion() string { + if x != nil { + return x.ScheduledVersion + } + return "" +} + +type StartedAt struct { + state protoimpl.MessageState `protogen:"open.v1"` + StartedAt int64 `protobuf:"varint,1,opt,name=startedAt,proto3" json:"startedAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartedAt) Reset() { + *x = StartedAt{} + mi := &file_daemon_started_service_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartedAt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartedAt) ProtoMessage() {} + +func (x *StartedAt) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartedAt.ProtoReflect.Descriptor instead. +func (*StartedAt) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{25} +} + +func (x *StartedAt) GetStartedAt() int64 { + if x != nil { + return x.StartedAt + } + return 0 +} + +type OutboundList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Outbounds []*GroupItem `protobuf:"bytes,1,rep,name=outbounds,proto3" json:"outbounds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OutboundList) Reset() { + *x = OutboundList{} + mi := &file_daemon_started_service_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OutboundList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OutboundList) ProtoMessage() {} + +func (x *OutboundList) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OutboundList.ProtoReflect.Descriptor instead. +func (*OutboundList) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{26} +} + +func (x *OutboundList) GetOutbounds() []*GroupItem { + if x != nil { + return x.Outbounds + } + return nil +} + +type NetworkQualityTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigURL string `protobuf:"bytes,1,opt,name=configURL,proto3" json:"configURL,omitempty"` + OutboundTag string `protobuf:"bytes,2,opt,name=outboundTag,proto3" json:"outboundTag,omitempty"` + Serial bool `protobuf:"varint,3,opt,name=serial,proto3" json:"serial,omitempty"` + MaxRuntimeSeconds int32 `protobuf:"varint,4,opt,name=maxRuntimeSeconds,proto3" json:"maxRuntimeSeconds,omitempty"` + Http3 bool `protobuf:"varint,5,opt,name=http3,proto3" json:"http3,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkQualityTestRequest) Reset() { + *x = NetworkQualityTestRequest{} + mi := &file_daemon_started_service_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkQualityTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkQualityTestRequest) ProtoMessage() {} + +func (x *NetworkQualityTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkQualityTestRequest.ProtoReflect.Descriptor instead. +func (*NetworkQualityTestRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{27} +} + +func (x *NetworkQualityTestRequest) GetConfigURL() string { + if x != nil { + return x.ConfigURL + } + return "" +} + +func (x *NetworkQualityTestRequest) GetOutboundTag() string { + if x != nil { + return x.OutboundTag + } + return "" +} + +func (x *NetworkQualityTestRequest) GetSerial() bool { + if x != nil { + return x.Serial + } + return false +} + +func (x *NetworkQualityTestRequest) GetMaxRuntimeSeconds() int32 { + if x != nil { + return x.MaxRuntimeSeconds + } + return 0 +} + +func (x *NetworkQualityTestRequest) GetHttp3() bool { + if x != nil { + return x.Http3 + } + return false +} + +type NetworkQualityTestProgress struct { + state protoimpl.MessageState `protogen:"open.v1"` + Phase int32 `protobuf:"varint,1,opt,name=phase,proto3" json:"phase,omitempty"` + DownloadCapacity int64 `protobuf:"varint,2,opt,name=downloadCapacity,proto3" json:"downloadCapacity,omitempty"` + UploadCapacity int64 `protobuf:"varint,3,opt,name=uploadCapacity,proto3" json:"uploadCapacity,omitempty"` + DownloadRPM int32 `protobuf:"varint,4,opt,name=downloadRPM,proto3" json:"downloadRPM,omitempty"` + UploadRPM int32 `protobuf:"varint,5,opt,name=uploadRPM,proto3" json:"uploadRPM,omitempty"` + IdleLatencyMs int32 `protobuf:"varint,6,opt,name=idleLatencyMs,proto3" json:"idleLatencyMs,omitempty"` + ElapsedMs int64 `protobuf:"varint,7,opt,name=elapsedMs,proto3" json:"elapsedMs,omitempty"` + IsFinal bool `protobuf:"varint,8,opt,name=isFinal,proto3" json:"isFinal,omitempty"` + Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + DownloadCapacityAccuracy int32 `protobuf:"varint,10,opt,name=downloadCapacityAccuracy,proto3" json:"downloadCapacityAccuracy,omitempty"` + UploadCapacityAccuracy int32 `protobuf:"varint,11,opt,name=uploadCapacityAccuracy,proto3" json:"uploadCapacityAccuracy,omitempty"` + DownloadRPMAccuracy int32 `protobuf:"varint,12,opt,name=downloadRPMAccuracy,proto3" json:"downloadRPMAccuracy,omitempty"` + UploadRPMAccuracy int32 `protobuf:"varint,13,opt,name=uploadRPMAccuracy,proto3" json:"uploadRPMAccuracy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkQualityTestProgress) Reset() { + *x = NetworkQualityTestProgress{} + mi := &file_daemon_started_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkQualityTestProgress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkQualityTestProgress) ProtoMessage() {} + +func (x *NetworkQualityTestProgress) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkQualityTestProgress.ProtoReflect.Descriptor instead. +func (*NetworkQualityTestProgress) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{28} +} + +func (x *NetworkQualityTestProgress) GetPhase() int32 { + if x != nil { + return x.Phase + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetDownloadCapacity() int64 { + if x != nil { + return x.DownloadCapacity + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetUploadCapacity() int64 { + if x != nil { + return x.UploadCapacity + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetDownloadRPM() int32 { + if x != nil { + return x.DownloadRPM + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetUploadRPM() int32 { + if x != nil { + return x.UploadRPM + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetIdleLatencyMs() int32 { + if x != nil { + return x.IdleLatencyMs + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetElapsedMs() int64 { + if x != nil { + return x.ElapsedMs + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetIsFinal() bool { + if x != nil { + return x.IsFinal + } + return false +} + +func (x *NetworkQualityTestProgress) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *NetworkQualityTestProgress) GetDownloadCapacityAccuracy() int32 { + if x != nil { + return x.DownloadCapacityAccuracy + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetUploadCapacityAccuracy() int32 { + if x != nil { + return x.UploadCapacityAccuracy + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetDownloadRPMAccuracy() int32 { + if x != nil { + return x.DownloadRPMAccuracy + } + return 0 +} + +func (x *NetworkQualityTestProgress) GetUploadRPMAccuracy() int32 { + if x != nil { + return x.UploadRPMAccuracy + } + return 0 +} + +type STUNTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + OutboundTag string `protobuf:"bytes,2,opt,name=outboundTag,proto3" json:"outboundTag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *STUNTestRequest) Reset() { + *x = STUNTestRequest{} + mi := &file_daemon_started_service_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *STUNTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*STUNTestRequest) ProtoMessage() {} + +func (x *STUNTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use STUNTestRequest.ProtoReflect.Descriptor instead. +func (*STUNTestRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{29} +} + +func (x *STUNTestRequest) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +func (x *STUNTestRequest) GetOutboundTag() string { + if x != nil { + return x.OutboundTag + } + return "" +} + +type STUNTestProgress struct { + state protoimpl.MessageState `protogen:"open.v1"` + Phase int32 `protobuf:"varint,1,opt,name=phase,proto3" json:"phase,omitempty"` + ExternalAddr string `protobuf:"bytes,2,opt,name=externalAddr,proto3" json:"externalAddr,omitempty"` + LatencyMs int32 `protobuf:"varint,3,opt,name=latencyMs,proto3" json:"latencyMs,omitempty"` + NatMapping int32 `protobuf:"varint,4,opt,name=natMapping,proto3" json:"natMapping,omitempty"` + NatFiltering int32 `protobuf:"varint,5,opt,name=natFiltering,proto3" json:"natFiltering,omitempty"` + IsFinal bool `protobuf:"varint,6,opt,name=isFinal,proto3" json:"isFinal,omitempty"` + Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + NatTypeSupported bool `protobuf:"varint,8,opt,name=natTypeSupported,proto3" json:"natTypeSupported,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *STUNTestProgress) Reset() { + *x = STUNTestProgress{} + mi := &file_daemon_started_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *STUNTestProgress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*STUNTestProgress) ProtoMessage() {} + +func (x *STUNTestProgress) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use STUNTestProgress.ProtoReflect.Descriptor instead. +func (*STUNTestProgress) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{30} +} + +func (x *STUNTestProgress) GetPhase() int32 { + if x != nil { + return x.Phase + } + return 0 +} + +func (x *STUNTestProgress) GetExternalAddr() string { + if x != nil { + return x.ExternalAddr + } + return "" +} + +func (x *STUNTestProgress) GetLatencyMs() int32 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +func (x *STUNTestProgress) GetNatMapping() int32 { + if x != nil { + return x.NatMapping + } + return 0 +} + +func (x *STUNTestProgress) GetNatFiltering() int32 { + if x != nil { + return x.NatFiltering + } + return 0 +} + +func (x *STUNTestProgress) GetIsFinal() bool { + if x != nil { + return x.IsFinal + } + return false +} + +func (x *STUNTestProgress) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *STUNTestProgress) GetNatTypeSupported() bool { + if x != nil { + return x.NatTypeSupported + } + return false +} + +type TailscaleStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoints []*TailscaleEndpointStatus `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscaleStatusUpdate) Reset() { + *x = TailscaleStatusUpdate{} + mi := &file_daemon_started_service_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscaleStatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscaleStatusUpdate) ProtoMessage() {} + +func (x *TailscaleStatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscaleStatusUpdate.ProtoReflect.Descriptor instead. +func (*TailscaleStatusUpdate) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{31} +} + +func (x *TailscaleStatusUpdate) GetEndpoints() []*TailscaleEndpointStatus { + if x != nil { + return x.Endpoints + } + return nil +} + +type TailscaleEndpointStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + BackendState string `protobuf:"bytes,2,opt,name=backendState,proto3" json:"backendState,omitempty"` + AuthURL string `protobuf:"bytes,3,opt,name=authURL,proto3" json:"authURL,omitempty"` + NetworkName string `protobuf:"bytes,4,opt,name=networkName,proto3" json:"networkName,omitempty"` + MagicDNSSuffix string `protobuf:"bytes,5,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"` + Self *TailscalePeer `protobuf:"bytes,6,opt,name=self,proto3" json:"self,omitempty"` + UserGroups []*TailscaleUserGroup `protobuf:"bytes,7,rep,name=userGroups,proto3" json:"userGroups,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscaleEndpointStatus) Reset() { + *x = TailscaleEndpointStatus{} + mi := &file_daemon_started_service_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscaleEndpointStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscaleEndpointStatus) ProtoMessage() {} + +func (x *TailscaleEndpointStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscaleEndpointStatus.ProtoReflect.Descriptor instead. +func (*TailscaleEndpointStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{32} +} + +func (x *TailscaleEndpointStatus) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *TailscaleEndpointStatus) GetBackendState() string { + if x != nil { + return x.BackendState + } + return "" +} + +func (x *TailscaleEndpointStatus) GetAuthURL() string { + if x != nil { + return x.AuthURL + } + return "" +} + +func (x *TailscaleEndpointStatus) GetNetworkName() string { + if x != nil { + return x.NetworkName + } + return "" +} + +func (x *TailscaleEndpointStatus) GetMagicDNSSuffix() string { + if x != nil { + return x.MagicDNSSuffix + } + return "" +} + +func (x *TailscaleEndpointStatus) GetSelf() *TailscalePeer { + if x != nil { + return x.Self + } + return nil +} + +func (x *TailscaleEndpointStatus) GetUserGroups() []*TailscaleUserGroup { + if x != nil { + return x.UserGroups + } + return nil +} + +type TailscaleUserGroup struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserID int64 `protobuf:"varint,1,opt,name=userID,proto3" json:"userID,omitempty"` + LoginName string `protobuf:"bytes,2,opt,name=loginName,proto3" json:"loginName,omitempty"` + DisplayName string `protobuf:"bytes,3,opt,name=displayName,proto3" json:"displayName,omitempty"` + ProfilePicURL string `protobuf:"bytes,4,opt,name=profilePicURL,proto3" json:"profilePicURL,omitempty"` + Peers []*TailscalePeer `protobuf:"bytes,5,rep,name=peers,proto3" json:"peers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscaleUserGroup) Reset() { + *x = TailscaleUserGroup{} + mi := &file_daemon_started_service_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscaleUserGroup) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscaleUserGroup) ProtoMessage() {} + +func (x *TailscaleUserGroup) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscaleUserGroup.ProtoReflect.Descriptor instead. +func (*TailscaleUserGroup) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{33} +} + +func (x *TailscaleUserGroup) GetUserID() int64 { + if x != nil { + return x.UserID + } + return 0 +} + +func (x *TailscaleUserGroup) GetLoginName() string { + if x != nil { + return x.LoginName + } + return "" +} + +func (x *TailscaleUserGroup) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *TailscaleUserGroup) GetProfilePicURL() string { + if x != nil { + return x.ProfilePicURL + } + return "" +} + +func (x *TailscaleUserGroup) GetPeers() []*TailscalePeer { + if x != nil { + return x.Peers + } + return nil +} + +type TailscalePeer struct { + state protoimpl.MessageState `protogen:"open.v1"` + HostName string `protobuf:"bytes,1,opt,name=hostName,proto3" json:"hostName,omitempty"` + DnsName string `protobuf:"bytes,2,opt,name=dnsName,proto3" json:"dnsName,omitempty"` + Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"` + TailscaleIPs []string `protobuf:"bytes,4,rep,name=tailscaleIPs,proto3" json:"tailscaleIPs,omitempty"` + Online bool `protobuf:"varint,5,opt,name=online,proto3" json:"online,omitempty"` + ExitNode bool `protobuf:"varint,6,opt,name=exitNode,proto3" json:"exitNode,omitempty"` + ExitNodeOption bool `protobuf:"varint,7,opt,name=exitNodeOption,proto3" json:"exitNodeOption,omitempty"` + Active bool `protobuf:"varint,8,opt,name=active,proto3" json:"active,omitempty"` + RxBytes int64 `protobuf:"varint,9,opt,name=rxBytes,proto3" json:"rxBytes,omitempty"` + TxBytes int64 `protobuf:"varint,10,opt,name=txBytes,proto3" json:"txBytes,omitempty"` + KeyExpiry int64 `protobuf:"varint,11,opt,name=keyExpiry,proto3" json:"keyExpiry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscalePeer) Reset() { + *x = TailscalePeer{} + mi := &file_daemon_started_service_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscalePeer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscalePeer) ProtoMessage() {} + +func (x *TailscalePeer) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscalePeer.ProtoReflect.Descriptor instead. +func (*TailscalePeer) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{34} +} + +func (x *TailscalePeer) GetHostName() string { + if x != nil { + return x.HostName + } + return "" +} + +func (x *TailscalePeer) GetDnsName() string { + if x != nil { + return x.DnsName + } + return "" +} + +func (x *TailscalePeer) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *TailscalePeer) GetTailscaleIPs() []string { + if x != nil { + return x.TailscaleIPs + } + return nil +} + +func (x *TailscalePeer) GetOnline() bool { + if x != nil { + return x.Online + } + return false +} + +func (x *TailscalePeer) GetExitNode() bool { + if x != nil { + return x.ExitNode + } + return false +} + +func (x *TailscalePeer) GetExitNodeOption() bool { + if x != nil { + return x.ExitNodeOption + } + return false +} + +func (x *TailscalePeer) GetActive() bool { + if x != nil { + return x.Active + } + return false +} + +func (x *TailscalePeer) GetRxBytes() int64 { + if x != nil { + return x.RxBytes + } + return 0 +} + +func (x *TailscalePeer) GetTxBytes() int64 { + if x != nil { + return x.TxBytes + } + return 0 +} + +func (x *TailscalePeer) GetKeyExpiry() int64 { + if x != nil { + return x.KeyExpiry + } + return 0 +} + +type TailscalePingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + PeerIP string `protobuf:"bytes,2,opt,name=peerIP,proto3" json:"peerIP,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscalePingRequest) Reset() { + *x = TailscalePingRequest{} + mi := &file_daemon_started_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscalePingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscalePingRequest) ProtoMessage() {} + +func (x *TailscalePingRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscalePingRequest.ProtoReflect.Descriptor instead. +func (*TailscalePingRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{35} +} + +func (x *TailscalePingRequest) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *TailscalePingRequest) GetPeerIP() string { + if x != nil { + return x.PeerIP + } + return "" +} + +type TailscalePingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + LatencyMs float64 `protobuf:"fixed64,1,opt,name=latencyMs,proto3" json:"latencyMs,omitempty"` + IsDirect bool `protobuf:"varint,2,opt,name=isDirect,proto3" json:"isDirect,omitempty"` + Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"` + DerpRegionID int32 `protobuf:"varint,4,opt,name=derpRegionID,proto3" json:"derpRegionID,omitempty"` + DerpRegionCode string `protobuf:"bytes,5,opt,name=derpRegionCode,proto3" json:"derpRegionCode,omitempty"` + Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscalePingResponse) Reset() { + *x = TailscalePingResponse{} + mi := &file_daemon_started_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscalePingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscalePingResponse) ProtoMessage() {} + +func (x *TailscalePingResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscalePingResponse.ProtoReflect.Descriptor instead. +func (*TailscalePingResponse) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{36} +} + +func (x *TailscalePingResponse) GetLatencyMs() float64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +func (x *TailscalePingResponse) GetIsDirect() bool { + if x != nil { + return x.IsDirect + } + return false +} + +func (x *TailscalePingResponse) GetEndpoint() string { + if x != nil { + return x.Endpoint + } + return "" +} + +func (x *TailscalePingResponse) GetDerpRegionID() int32 { + if x != nil { + return x.DerpRegionID + } + return 0 +} + +func (x *TailscalePingResponse) GetDerpRegionCode() string { + if x != nil { + return x.DerpRegionCode + } + return "" +} + +func (x *TailscalePingResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type Log_Message struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level LogLevel `protobuf:"varint,1,opt,name=level,proto3,enum=daemon.LogLevel" json:"level,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Log_Message) Reset() { + *x = Log_Message{} + mi := &file_daemon_started_service_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Log_Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Log_Message) ProtoMessage() {} + +func (x *Log_Message) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Log_Message.ProtoReflect.Descriptor instead. +func (*Log_Message) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{3, 0} +} + +func (x *Log_Message) GetLevel() LogLevel { + if x != nil { + return x.Level + } + return LogLevel_PANIC +} + +func (x *Log_Message) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_daemon_started_service_proto protoreflect.FileDescriptor + +const file_daemon_started_service_proto_rawDesc = "" + + "\n" + + "\x1cdaemon/started_service.proto\x12\x06daemon\x1a\x1bgoogle/protobuf/empty.proto\"\xad\x01\n" + + "\rServiceStatus\x122\n" + + "\x06status\x18\x01 \x01(\x0e2\x1a.daemon.ServiceStatus.TypeR\x06status\x12\"\n" + + "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\"D\n" + + "\x04Type\x12\b\n" + + "\x04IDLE\x10\x00\x12\f\n" + + "\bSTARTING\x10\x01\x12\v\n" + + "\aSTARTED\x10\x02\x12\f\n" + + "\bSTOPPING\x10\x03\x12\t\n" + + "\x05FATAL\x10\x04\"D\n" + + "\x14ReloadServiceRequest\x12,\n" + + "\x11newProfileContent\x18\x01 \x01(\tR\x11newProfileContent\"4\n" + + "\x16SubscribeStatusRequest\x12\x1a\n" + + "\binterval\x18\x01 \x01(\x03R\binterval\"\x99\x01\n" + + "\x03Log\x12/\n" + + "\bmessages\x18\x01 \x03(\v2\x13.daemon.Log.MessageR\bmessages\x12\x14\n" + + "\x05reset\x18\x02 \x01(\bR\x05reset\x1aK\n" + + "\aMessage\x12&\n" + + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"9\n" + + "\x0fDefaultLogLevel\x12&\n" + + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\xb6\x02\n" + + "\x06Status\x12\x16\n" + + "\x06memory\x18\x01 \x01(\x04R\x06memory\x12\x1e\n" + + "\n" + + "goroutines\x18\x02 \x01(\x05R\n" + + "goroutines\x12$\n" + + "\rconnectionsIn\x18\x03 \x01(\x05R\rconnectionsIn\x12&\n" + + "\x0econnectionsOut\x18\x04 \x01(\x05R\x0econnectionsOut\x12*\n" + + "\x10trafficAvailable\x18\x05 \x01(\bR\x10trafficAvailable\x12\x16\n" + + "\x06uplink\x18\x06 \x01(\x03R\x06uplink\x12\x1a\n" + + "\bdownlink\x18\a \x01(\x03R\bdownlink\x12 \n" + + "\vuplinkTotal\x18\b \x01(\x03R\vuplinkTotal\x12$\n" + + "\rdownlinkTotal\x18\t \x01(\x03R\rdownlinkTotal\"-\n" + + "\x06Groups\x12#\n" + + "\x05group\x18\x01 \x03(\v2\r.daemon.GroupR\x05group\"\xae\x01\n" + + "\x05Group\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1e\n" + + "\n" + + "selectable\x18\x03 \x01(\bR\n" + + "selectable\x12\x1a\n" + + "\bselected\x18\x04 \x01(\tR\bselected\x12\x1a\n" + + "\bisExpand\x18\x05 \x01(\bR\bisExpand\x12'\n" + + "\x05items\x18\x06 \x03(\v2\x11.daemon.GroupItemR\x05items\"w\n" + + "\tGroupItem\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12 \n" + + "\vurlTestTime\x18\x03 \x01(\x03R\vurlTestTime\x12\"\n" + + "\furlTestDelay\x18\x04 \x01(\x05R\furlTestDelay\"2\n" + + "\x0eURLTestRequest\x12 \n" + + "\voutboundTag\x18\x01 \x01(\tR\voutboundTag\"U\n" + + "\x15SelectOutboundRequest\x12\x1a\n" + + "\bgroupTag\x18\x01 \x01(\tR\bgroupTag\x12 \n" + + "\voutboundTag\x18\x02 \x01(\tR\voutboundTag\"O\n" + + "\x15SetGroupExpandRequest\x12\x1a\n" + + "\bgroupTag\x18\x01 \x01(\tR\bgroupTag\x12\x1a\n" + + "\bisExpand\x18\x02 \x01(\bR\bisExpand\"\x1f\n" + + "\tClashMode\x12\x12\n" + + "\x04mode\x18\x03 \x01(\tR\x04mode\"O\n" + + "\x0fClashModeStatus\x12\x1a\n" + + "\bmodeList\x18\x01 \x03(\tR\bmodeList\x12 \n" + + "\vcurrentMode\x18\x02 \x01(\tR\vcurrentMode\"K\n" + + "\x11SystemProxyStatus\x12\x1c\n" + + "\tavailable\x18\x01 \x01(\bR\tavailable\x12\x18\n" + + "\aenabled\x18\x02 \x01(\bR\aenabled\"8\n" + + "\x1cSetSystemProxyEnabledRequest\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\"c\n" + + "\x11DebugCrashRequest\x122\n" + + "\x04type\x18\x01 \x01(\x0e2\x1e.daemon.DebugCrashRequest.TypeR\x04type\"\x1a\n" + + "\x04Type\x12\x06\n" + + "\x02GO\x10\x00\x12\n" + + "\n" + + "\x06NATIVE\x10\x01\"9\n" + + "\x1bSubscribeConnectionsRequest\x12\x1a\n" + + "\binterval\x18\x01 \x01(\x03R\binterval\"\xea\x01\n" + + "\x0fConnectionEvent\x12/\n" + + "\x04type\x18\x01 \x01(\x0e2\x1b.daemon.ConnectionEventTypeR\x04type\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x122\n" + + "\n" + + "connection\x18\x03 \x01(\v2\x12.daemon.ConnectionR\n" + + "connection\x12 \n" + + "\vuplinkDelta\x18\x04 \x01(\x03R\vuplinkDelta\x12$\n" + + "\rdownlinkDelta\x18\x05 \x01(\x03R\rdownlinkDelta\x12\x1a\n" + + "\bclosedAt\x18\x06 \x01(\x03R\bclosedAt\"Y\n" + + "\x10ConnectionEvents\x12/\n" + + "\x06events\x18\x01 \x03(\v2\x17.daemon.ConnectionEventR\x06events\x12\x14\n" + + "\x05reset\x18\x02 \x01(\bR\x05reset\"\x95\x05\n" + + "\n" + + "Connection\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + + "\ainbound\x18\x02 \x01(\tR\ainbound\x12 \n" + + "\vinboundType\x18\x03 \x01(\tR\vinboundType\x12\x1c\n" + + "\tipVersion\x18\x04 \x01(\x05R\tipVersion\x12\x18\n" + + "\anetwork\x18\x05 \x01(\tR\anetwork\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\x12 \n" + + "\vdestination\x18\a \x01(\tR\vdestination\x12\x16\n" + + "\x06domain\x18\b \x01(\tR\x06domain\x12\x1a\n" + + "\bprotocol\x18\t \x01(\tR\bprotocol\x12\x12\n" + + "\x04user\x18\n" + + " \x01(\tR\x04user\x12\"\n" + + "\ffromOutbound\x18\v \x01(\tR\ffromOutbound\x12\x1c\n" + + "\tcreatedAt\x18\f \x01(\x03R\tcreatedAt\x12\x1a\n" + + "\bclosedAt\x18\r \x01(\x03R\bclosedAt\x12\x16\n" + + "\x06uplink\x18\x0e \x01(\x03R\x06uplink\x12\x1a\n" + + "\bdownlink\x18\x0f \x01(\x03R\bdownlink\x12 \n" + + "\vuplinkTotal\x18\x10 \x01(\x03R\vuplinkTotal\x12$\n" + + "\rdownlinkTotal\x18\x11 \x01(\x03R\rdownlinkTotal\x12\x12\n" + + "\x04rule\x18\x12 \x01(\tR\x04rule\x12\x1a\n" + + "\boutbound\x18\x13 \x01(\tR\boutbound\x12\"\n" + + "\foutboundType\x18\x14 \x01(\tR\foutboundType\x12\x1c\n" + + "\tchainList\x18\x15 \x03(\tR\tchainList\x125\n" + + "\vprocessInfo\x18\x16 \x01(\v2\x13.daemon.ProcessInfoR\vprocessInfo\"\xa5\x01\n" + + "\vProcessInfo\x12\x1c\n" + + "\tprocessId\x18\x01 \x01(\rR\tprocessId\x12\x16\n" + + "\x06userId\x18\x02 \x01(\x05R\x06userId\x12\x1a\n" + + "\buserName\x18\x03 \x01(\tR\buserName\x12 \n" + + "\vprocessPath\x18\x04 \x01(\tR\vprocessPath\x12\"\n" + + "\fpackageNames\x18\x05 \x03(\tR\fpackageNames\"(\n" + + "\x16CloseConnectionRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"K\n" + + "\x12DeprecatedWarnings\x125\n" + + "\bwarnings\x18\x01 \x03(\v2\x19.daemon.DeprecatedWarningR\bwarnings\"\xed\x01\n" + + "\x11DeprecatedWarning\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x1c\n" + + "\timpending\x18\x02 \x01(\bR\timpending\x12$\n" + + "\rmigrationLink\x18\x03 \x01(\tR\rmigrationLink\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\x12,\n" + + "\x11deprecatedVersion\x18\x05 \x01(\tR\x11deprecatedVersion\x12*\n" + + "\x10scheduledVersion\x18\x06 \x01(\tR\x10scheduledVersion\")\n" + + "\tStartedAt\x12\x1c\n" + + "\tstartedAt\x18\x01 \x01(\x03R\tstartedAt\"?\n" + + "\fOutboundList\x12/\n" + + "\toutbounds\x18\x01 \x03(\v2\x11.daemon.GroupItemR\toutbounds\"\xb7\x01\n" + + "\x19NetworkQualityTestRequest\x12\x1c\n" + + "\tconfigURL\x18\x01 \x01(\tR\tconfigURL\x12 \n" + + "\voutboundTag\x18\x02 \x01(\tR\voutboundTag\x12\x16\n" + + "\x06serial\x18\x03 \x01(\bR\x06serial\x12,\n" + + "\x11maxRuntimeSeconds\x18\x04 \x01(\x05R\x11maxRuntimeSeconds\x12\x14\n" + + "\x05http3\x18\x05 \x01(\bR\x05http3\"\x8e\x04\n" + + "\x1aNetworkQualityTestProgress\x12\x14\n" + + "\x05phase\x18\x01 \x01(\x05R\x05phase\x12*\n" + + "\x10downloadCapacity\x18\x02 \x01(\x03R\x10downloadCapacity\x12&\n" + + "\x0euploadCapacity\x18\x03 \x01(\x03R\x0euploadCapacity\x12 \n" + + "\vdownloadRPM\x18\x04 \x01(\x05R\vdownloadRPM\x12\x1c\n" + + "\tuploadRPM\x18\x05 \x01(\x05R\tuploadRPM\x12$\n" + + "\ridleLatencyMs\x18\x06 \x01(\x05R\ridleLatencyMs\x12\x1c\n" + + "\telapsedMs\x18\a \x01(\x03R\telapsedMs\x12\x18\n" + + "\aisFinal\x18\b \x01(\bR\aisFinal\x12\x14\n" + + "\x05error\x18\t \x01(\tR\x05error\x12:\n" + + "\x18downloadCapacityAccuracy\x18\n" + + " \x01(\x05R\x18downloadCapacityAccuracy\x126\n" + + "\x16uploadCapacityAccuracy\x18\v \x01(\x05R\x16uploadCapacityAccuracy\x120\n" + + "\x13downloadRPMAccuracy\x18\f \x01(\x05R\x13downloadRPMAccuracy\x12,\n" + + "\x11uploadRPMAccuracy\x18\r \x01(\x05R\x11uploadRPMAccuracy\"K\n" + + "\x0fSTUNTestRequest\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\x12 \n" + + "\voutboundTag\x18\x02 \x01(\tR\voutboundTag\"\x8a\x02\n" + + "\x10STUNTestProgress\x12\x14\n" + + "\x05phase\x18\x01 \x01(\x05R\x05phase\x12\"\n" + + "\fexternalAddr\x18\x02 \x01(\tR\fexternalAddr\x12\x1c\n" + + "\tlatencyMs\x18\x03 \x01(\x05R\tlatencyMs\x12\x1e\n" + + "\n" + + "natMapping\x18\x04 \x01(\x05R\n" + + "natMapping\x12\"\n" + + "\fnatFiltering\x18\x05 \x01(\x05R\fnatFiltering\x12\x18\n" + + "\aisFinal\x18\x06 \x01(\bR\aisFinal\x12\x14\n" + + "\x05error\x18\a \x01(\tR\x05error\x12*\n" + + "\x10natTypeSupported\x18\b \x01(\bR\x10natTypeSupported\"V\n" + + "\x15TailscaleStatusUpdate\x12=\n" + + "\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\xaa\x02\n" + + "\x17TailscaleEndpointStatus\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\"\n" + + "\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x18\n" + + "\aauthURL\x18\x03 \x01(\tR\aauthURL\x12 \n" + + "\vnetworkName\x18\x04 \x01(\tR\vnetworkName\x12&\n" + + "\x0emagicDNSSuffix\x18\x05 \x01(\tR\x0emagicDNSSuffix\x12)\n" + + "\x04self\x18\x06 \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" + + "\n" + + "userGroups\x18\a \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" + + "userGroups\"\xbf\x01\n" + + "\x12TailscaleUserGroup\x12\x16\n" + + "\x06userID\x18\x01 \x01(\x03R\x06userID\x12\x1c\n" + + "\tloginName\x18\x02 \x01(\tR\tloginName\x12 \n" + + "\vdisplayName\x18\x03 \x01(\tR\vdisplayName\x12$\n" + + "\rprofilePicURL\x18\x04 \x01(\tR\rprofilePicURL\x12+\n" + + "\x05peers\x18\x05 \x03(\v2\x15.daemon.TailscalePeerR\x05peers\"\xbf\x02\n" + + "\rTailscalePeer\x12\x1a\n" + + "\bhostName\x18\x01 \x01(\tR\bhostName\x12\x18\n" + + "\adnsName\x18\x02 \x01(\tR\adnsName\x12\x0e\n" + + "\x02os\x18\x03 \x01(\tR\x02os\x12\"\n" + + "\ftailscaleIPs\x18\x04 \x03(\tR\ftailscaleIPs\x12\x16\n" + + "\x06online\x18\x05 \x01(\bR\x06online\x12\x1a\n" + + "\bexitNode\x18\x06 \x01(\bR\bexitNode\x12&\n" + + "\x0eexitNodeOption\x18\a \x01(\bR\x0eexitNodeOption\x12\x16\n" + + "\x06active\x18\b \x01(\bR\x06active\x12\x18\n" + + "\arxBytes\x18\t \x01(\x03R\arxBytes\x12\x18\n" + + "\atxBytes\x18\n" + + " \x01(\x03R\atxBytes\x12\x1c\n" + + "\tkeyExpiry\x18\v \x01(\x03R\tkeyExpiry\"P\n" + + "\x14TailscalePingRequest\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + + "\x06peerIP\x18\x02 \x01(\tR\x06peerIP\"\xcf\x01\n" + + "\x15TailscalePingResponse\x12\x1c\n" + + "\tlatencyMs\x18\x01 \x01(\x01R\tlatencyMs\x12\x1a\n" + + "\bisDirect\x18\x02 \x01(\bR\bisDirect\x12\x1a\n" + + "\bendpoint\x18\x03 \x01(\tR\bendpoint\x12\"\n" + + "\fderpRegionID\x18\x04 \x01(\x05R\fderpRegionID\x12&\n" + + "\x0ederpRegionCode\x18\x05 \x01(\tR\x0ederpRegionCode\x12\x14\n" + + "\x05error\x18\x06 \x01(\tR\x05error*U\n" + + "\bLogLevel\x12\t\n" + + "\x05PANIC\x10\x00\x12\t\n" + + "\x05FATAL\x10\x01\x12\t\n" + + "\x05ERROR\x10\x02\x12\b\n" + + "\x04WARN\x10\x03\x12\b\n" + + "\x04INFO\x10\x04\x12\t\n" + + "\x05DEBUG\x10\x05\x12\t\n" + + "\x05TRACE\x10\x06*i\n" + + "\x13ConnectionEventType\x12\x18\n" + + "\x14CONNECTION_EVENT_NEW\x10\x00\x12\x1b\n" + + "\x17CONNECTION_EVENT_UPDATE\x10\x01\x12\x1b\n" + + "\x17CONNECTION_EVENT_CLOSED\x10\x022\x99\x10\n" + + "\x0eStartedService\x12=\n" + + "\vStopService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12?\n" + + "\rReloadService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12K\n" + + "\x16SubscribeServiceStatus\x12\x16.google.protobuf.Empty\x1a\x15.daemon.ServiceStatus\"\x000\x01\x127\n" + + "\fSubscribeLog\x12\x16.google.protobuf.Empty\x1a\v.daemon.Log\"\x000\x01\x12G\n" + + "\x12GetDefaultLogLevel\x12\x16.google.protobuf.Empty\x1a\x17.daemon.DefaultLogLevel\"\x00\x12=\n" + + "\tClearLogs\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12E\n" + + "\x0fSubscribeStatus\x12\x1e.daemon.SubscribeStatusRequest\x1a\x0e.daemon.Status\"\x000\x01\x12=\n" + + "\x0fSubscribeGroups\x12\x16.google.protobuf.Empty\x1a\x0e.daemon.Groups\"\x000\x01\x12G\n" + + "\x12GetClashModeStatus\x12\x16.google.protobuf.Empty\x1a\x17.daemon.ClashModeStatus\"\x00\x12C\n" + + "\x12SubscribeClashMode\x12\x16.google.protobuf.Empty\x1a\x11.daemon.ClashMode\"\x000\x01\x12;\n" + + "\fSetClashMode\x12\x11.daemon.ClashMode\x1a\x16.google.protobuf.Empty\"\x00\x12;\n" + + "\aURLTest\x12\x16.daemon.URLTestRequest\x1a\x16.google.protobuf.Empty\"\x00\x12I\n" + + "\x0eSelectOutbound\x12\x1d.daemon.SelectOutboundRequest\x1a\x16.google.protobuf.Empty\"\x00\x12I\n" + + "\x0eSetGroupExpand\x12\x1d.daemon.SetGroupExpandRequest\x1a\x16.google.protobuf.Empty\"\x00\x12K\n" + + "\x14GetSystemProxyStatus\x12\x16.google.protobuf.Empty\x1a\x19.daemon.SystemProxyStatus\"\x00\x12W\n" + + "\x15SetSystemProxyEnabled\x12$.daemon.SetSystemProxyEnabledRequest\x1a\x16.google.protobuf.Empty\"\x00\x12H\n" + + "\x11TriggerDebugCrash\x12\x19.daemon.DebugCrashRequest\x1a\x16.google.protobuf.Empty\"\x00\x12D\n" + + "\x10TriggerOOMReport\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12Y\n" + + "\x14SubscribeConnections\x12#.daemon.SubscribeConnectionsRequest\x1a\x18.daemon.ConnectionEvents\"\x000\x01\x12K\n" + + "\x0fCloseConnection\x12\x1e.daemon.CloseConnectionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + + "\x13CloseAllConnections\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12M\n" + + "\x15GetDeprecatedWarnings\x12\x16.google.protobuf.Empty\x1a\x1a.daemon.DeprecatedWarnings\"\x00\x12;\n" + + "\fGetStartedAt\x12\x16.google.protobuf.Empty\x1a\x11.daemon.StartedAt\"\x00\x12F\n" + + "\x12SubscribeOutbounds\x12\x16.google.protobuf.Empty\x1a\x14.daemon.OutboundList\"\x000\x01\x12d\n" + + "\x17StartNetworkQualityTest\x12!.daemon.NetworkQualityTestRequest\x1a\".daemon.NetworkQualityTestProgress\"\x000\x01\x12F\n" + + "\rStartSTUNTest\x12\x17.daemon.STUNTestRequest\x1a\x18.daemon.STUNTestProgress\"\x000\x01\x12U\n" + + "\x18SubscribeTailscaleStatus\x12\x16.google.protobuf.Empty\x1a\x1d.daemon.TailscaleStatusUpdate\"\x000\x01\x12U\n" + + "\x12StartTailscalePing\x12\x1c.daemon.TailscalePingRequest\x1a\x1d.daemon.TailscalePingResponse\"\x000\x01B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" + +var ( + file_daemon_started_service_proto_rawDescOnce sync.Once + file_daemon_started_service_proto_rawDescData []byte +) + +func file_daemon_started_service_proto_rawDescGZIP() []byte { + file_daemon_started_service_proto_rawDescOnce.Do(func() { + file_daemon_started_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_daemon_started_service_proto_rawDesc), len(file_daemon_started_service_proto_rawDesc))) + }) + return file_daemon_started_service_proto_rawDescData +} + +var ( + file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 4) + file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 38) + file_daemon_started_service_proto_goTypes = []any{ + (LogLevel)(0), // 0: daemon.LogLevel + (ConnectionEventType)(0), // 1: daemon.ConnectionEventType + (ServiceStatus_Type)(0), // 2: daemon.ServiceStatus.Type + (DebugCrashRequest_Type)(0), // 3: daemon.DebugCrashRequest.Type + (*ServiceStatus)(nil), // 4: daemon.ServiceStatus + (*ReloadServiceRequest)(nil), // 5: daemon.ReloadServiceRequest + (*SubscribeStatusRequest)(nil), // 6: daemon.SubscribeStatusRequest + (*Log)(nil), // 7: daemon.Log + (*DefaultLogLevel)(nil), // 8: daemon.DefaultLogLevel + (*Status)(nil), // 9: daemon.Status + (*Groups)(nil), // 10: daemon.Groups + (*Group)(nil), // 11: daemon.Group + (*GroupItem)(nil), // 12: daemon.GroupItem + (*URLTestRequest)(nil), // 13: daemon.URLTestRequest + (*SelectOutboundRequest)(nil), // 14: daemon.SelectOutboundRequest + (*SetGroupExpandRequest)(nil), // 15: daemon.SetGroupExpandRequest + (*ClashMode)(nil), // 16: daemon.ClashMode + (*ClashModeStatus)(nil), // 17: daemon.ClashModeStatus + (*SystemProxyStatus)(nil), // 18: daemon.SystemProxyStatus + (*SetSystemProxyEnabledRequest)(nil), // 19: daemon.SetSystemProxyEnabledRequest + (*DebugCrashRequest)(nil), // 20: daemon.DebugCrashRequest + (*SubscribeConnectionsRequest)(nil), // 21: daemon.SubscribeConnectionsRequest + (*ConnectionEvent)(nil), // 22: daemon.ConnectionEvent + (*ConnectionEvents)(nil), // 23: daemon.ConnectionEvents + (*Connection)(nil), // 24: daemon.Connection + (*ProcessInfo)(nil), // 25: daemon.ProcessInfo + (*CloseConnectionRequest)(nil), // 26: daemon.CloseConnectionRequest + (*DeprecatedWarnings)(nil), // 27: daemon.DeprecatedWarnings + (*DeprecatedWarning)(nil), // 28: daemon.DeprecatedWarning + (*StartedAt)(nil), // 29: daemon.StartedAt + (*OutboundList)(nil), // 30: daemon.OutboundList + (*NetworkQualityTestRequest)(nil), // 31: daemon.NetworkQualityTestRequest + (*NetworkQualityTestProgress)(nil), // 32: daemon.NetworkQualityTestProgress + (*STUNTestRequest)(nil), // 33: daemon.STUNTestRequest + (*STUNTestProgress)(nil), // 34: daemon.STUNTestProgress + (*TailscaleStatusUpdate)(nil), // 35: daemon.TailscaleStatusUpdate + (*TailscaleEndpointStatus)(nil), // 36: daemon.TailscaleEndpointStatus + (*TailscaleUserGroup)(nil), // 37: daemon.TailscaleUserGroup + (*TailscalePeer)(nil), // 38: daemon.TailscalePeer + (*TailscalePingRequest)(nil), // 39: daemon.TailscalePingRequest + (*TailscalePingResponse)(nil), // 40: daemon.TailscalePingResponse + (*Log_Message)(nil), // 41: daemon.Log.Message + (*emptypb.Empty)(nil), // 42: google.protobuf.Empty + } +) + +var file_daemon_started_service_proto_depIdxs = []int32{ + 2, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type + 41, // 1: daemon.Log.messages:type_name -> daemon.Log.Message + 0, // 2: daemon.DefaultLogLevel.level:type_name -> daemon.LogLevel + 11, // 3: daemon.Groups.group:type_name -> daemon.Group + 12, // 4: daemon.Group.items:type_name -> daemon.GroupItem + 3, // 5: daemon.DebugCrashRequest.type:type_name -> daemon.DebugCrashRequest.Type + 1, // 6: daemon.ConnectionEvent.type:type_name -> daemon.ConnectionEventType + 24, // 7: daemon.ConnectionEvent.connection:type_name -> daemon.Connection + 22, // 8: daemon.ConnectionEvents.events:type_name -> daemon.ConnectionEvent + 25, // 9: daemon.Connection.processInfo:type_name -> daemon.ProcessInfo + 28, // 10: daemon.DeprecatedWarnings.warnings:type_name -> daemon.DeprecatedWarning + 12, // 11: daemon.OutboundList.outbounds:type_name -> daemon.GroupItem + 36, // 12: daemon.TailscaleStatusUpdate.endpoints:type_name -> daemon.TailscaleEndpointStatus + 38, // 13: daemon.TailscaleEndpointStatus.self:type_name -> daemon.TailscalePeer + 37, // 14: daemon.TailscaleEndpointStatus.userGroups:type_name -> daemon.TailscaleUserGroup + 38, // 15: daemon.TailscaleUserGroup.peers:type_name -> daemon.TailscalePeer + 0, // 16: daemon.Log.Message.level:type_name -> daemon.LogLevel + 42, // 17: daemon.StartedService.StopService:input_type -> google.protobuf.Empty + 42, // 18: daemon.StartedService.ReloadService:input_type -> google.protobuf.Empty + 42, // 19: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty + 42, // 20: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty + 42, // 21: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty + 42, // 22: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty + 6, // 23: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest + 42, // 24: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty + 42, // 25: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty + 42, // 26: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty + 16, // 27: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode + 13, // 28: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest + 14, // 29: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest + 15, // 30: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest + 42, // 31: daemon.StartedService.GetSystemProxyStatus:input_type -> google.protobuf.Empty + 19, // 32: daemon.StartedService.SetSystemProxyEnabled:input_type -> daemon.SetSystemProxyEnabledRequest + 20, // 33: daemon.StartedService.TriggerDebugCrash:input_type -> daemon.DebugCrashRequest + 42, // 34: daemon.StartedService.TriggerOOMReport:input_type -> google.protobuf.Empty + 21, // 35: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest + 26, // 36: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest + 42, // 37: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty + 42, // 38: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty + 42, // 39: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty + 42, // 40: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty + 31, // 41: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest + 33, // 42: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest + 42, // 43: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty + 39, // 44: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest + 42, // 45: daemon.StartedService.StopService:output_type -> google.protobuf.Empty + 42, // 46: daemon.StartedService.ReloadService:output_type -> google.protobuf.Empty + 4, // 47: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus + 7, // 48: daemon.StartedService.SubscribeLog:output_type -> daemon.Log + 8, // 49: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel + 42, // 50: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty + 9, // 51: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status + 10, // 52: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups + 17, // 53: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus + 16, // 54: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode + 42, // 55: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty + 42, // 56: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty + 42, // 57: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty + 42, // 58: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty + 18, // 59: daemon.StartedService.GetSystemProxyStatus:output_type -> daemon.SystemProxyStatus + 42, // 60: daemon.StartedService.SetSystemProxyEnabled:output_type -> google.protobuf.Empty + 42, // 61: daemon.StartedService.TriggerDebugCrash:output_type -> google.protobuf.Empty + 42, // 62: daemon.StartedService.TriggerOOMReport:output_type -> google.protobuf.Empty + 23, // 63: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents + 42, // 64: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty + 42, // 65: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty + 27, // 66: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings + 29, // 67: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt + 30, // 68: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList + 32, // 69: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 34, // 70: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress + 35, // 71: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate + 40, // 72: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse + 45, // [45:73] is the sub-list for method output_type + 17, // [17:45] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name +} + +func init() { file_daemon_started_service_proto_init() } +func file_daemon_started_service_proto_init() { + if File_daemon_started_service_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_started_service_proto_rawDesc), len(file_daemon_started_service_proto_rawDesc)), + NumEnums: 4, + NumMessages: 38, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_daemon_started_service_proto_goTypes, + DependencyIndexes: file_daemon_started_service_proto_depIdxs, + EnumInfos: file_daemon_started_service_proto_enumTypes, + MessageInfos: file_daemon_started_service_proto_msgTypes, + }.Build() + File_daemon_started_service_proto = out.File + file_daemon_started_service_proto_goTypes = nil + file_daemon_started_service_proto_depIdxs = nil +} diff --git a/daemon/started_service.proto b/daemon/started_service.proto new file mode 100644 index 00000000..2c3140a9 --- /dev/null +++ b/daemon/started_service.proto @@ -0,0 +1,331 @@ +syntax = "proto3"; + +package daemon; +option go_package = "github.com/sagernet/sing-box/daemon"; + +import "google/protobuf/empty.proto"; + +service StartedService { + rpc StopService(google.protobuf.Empty) returns (google.protobuf.Empty); + rpc ReloadService(google.protobuf.Empty) returns (google.protobuf.Empty); + + rpc SubscribeServiceStatus(google.protobuf.Empty) returns(stream ServiceStatus) {} + rpc SubscribeLog(google.protobuf.Empty) returns(stream Log) {} + rpc GetDefaultLogLevel(google.protobuf.Empty) returns(DefaultLogLevel) {} + rpc ClearLogs(google.protobuf.Empty) returns(google.protobuf.Empty) {} + rpc SubscribeStatus(SubscribeStatusRequest) returns(stream Status) {} + rpc SubscribeGroups(google.protobuf.Empty) returns(stream Groups) {} + + rpc GetClashModeStatus(google.protobuf.Empty) returns(ClashModeStatus) {} + rpc SubscribeClashMode(google.protobuf.Empty) returns(stream ClashMode) {} + rpc SetClashMode(ClashMode) returns(google.protobuf.Empty) {} + + rpc URLTest(URLTestRequest) returns(google.protobuf.Empty) {} + rpc SelectOutbound(SelectOutboundRequest) returns (google.protobuf.Empty) {} + rpc SetGroupExpand(SetGroupExpandRequest) returns (google.protobuf.Empty) {} + + rpc GetSystemProxyStatus(google.protobuf.Empty) returns(SystemProxyStatus) {} + rpc SetSystemProxyEnabled(SetSystemProxyEnabledRequest) returns(google.protobuf.Empty) {} + rpc TriggerDebugCrash(DebugCrashRequest) returns(google.protobuf.Empty) {} + rpc TriggerOOMReport(google.protobuf.Empty) returns(google.protobuf.Empty) {} + + rpc SubscribeConnections(SubscribeConnectionsRequest) returns(stream ConnectionEvents) {} + rpc CloseConnection(CloseConnectionRequest) returns(google.protobuf.Empty) {} + rpc CloseAllConnections(google.protobuf.Empty) returns(google.protobuf.Empty) {} + rpc GetDeprecatedWarnings(google.protobuf.Empty) returns(DeprecatedWarnings) {} + rpc GetStartedAt(google.protobuf.Empty) returns(StartedAt) {} + + rpc SubscribeOutbounds(google.protobuf.Empty) returns (stream OutboundList) {} + rpc StartNetworkQualityTest(NetworkQualityTestRequest) returns (stream NetworkQualityTestProgress) {} + rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {} + rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {} + rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {} +} + +message ServiceStatus { + enum Type { + IDLE = 0; + STARTING = 1; + STARTED = 2; + STOPPING = 3; + FATAL = 4; + } + Type status = 1; + string errorMessage = 2; +} + +message ReloadServiceRequest { + string newProfileContent = 1; +} + +message SubscribeStatusRequest { + int64 interval = 1; +} + +enum LogLevel { + PANIC = 0; + FATAL = 1; + ERROR = 2; + WARN = 3; + INFO = 4; + DEBUG = 5; + TRACE = 6; +} + +message Log { + repeated Message messages = 1; + bool reset = 2; + message Message { + LogLevel level = 1; + string message = 2; + } +} + +message DefaultLogLevel { + LogLevel level = 1; +} + +message Status { + uint64 memory = 1; + int32 goroutines = 2; + int32 connectionsIn = 3; + int32 connectionsOut = 4; + bool trafficAvailable = 5; + int64 uplink = 6; + int64 downlink = 7; + int64 uplinkTotal = 8; + int64 downlinkTotal = 9; +} + +message Groups { + repeated Group group = 1; +} + +message Group { + string tag = 1; + string type = 2; + bool selectable = 3; + string selected = 4; + bool isExpand = 5; + repeated GroupItem items = 6; +} + +message GroupItem { + string tag = 1; + string type = 2; + int64 urlTestTime = 3; + int32 urlTestDelay = 4; +} + +message URLTestRequest { + string outboundTag = 1; +} + +message SelectOutboundRequest { + string groupTag = 1; + string outboundTag = 2; +} + +message SetGroupExpandRequest { + string groupTag = 1; + bool isExpand = 2; +} + +message ClashMode { + string mode = 3; +} + +message ClashModeStatus { + repeated string modeList = 1; + string currentMode = 2; +} + +message SystemProxyStatus { + bool available = 1; + bool enabled = 2; +} + +message SetSystemProxyEnabledRequest { + bool enabled = 1; +} + +message DebugCrashRequest { + enum Type { + GO = 0; + NATIVE = 1; + } + + Type type = 1; +} + +message SubscribeConnectionsRequest { + int64 interval = 1; +} + +enum ConnectionEventType { + CONNECTION_EVENT_NEW = 0; + CONNECTION_EVENT_UPDATE = 1; + CONNECTION_EVENT_CLOSED = 2; +} + +message ConnectionEvent { + ConnectionEventType type = 1; + string id = 2; + Connection connection = 3; + int64 uplinkDelta = 4; + int64 downlinkDelta = 5; + int64 closedAt = 6; +} + +message ConnectionEvents { + repeated ConnectionEvent events = 1; + bool reset = 2; +} + +message Connection { + string id = 1; + string inbound = 2; + string inboundType = 3; + int32 ipVersion = 4; + string network = 5; + string source = 6; + string destination = 7; + string domain = 8; + string protocol = 9; + string user = 10; + string fromOutbound = 11; + int64 createdAt = 12; + int64 closedAt = 13; + int64 uplink = 14; + int64 downlink = 15; + int64 uplinkTotal = 16; + int64 downlinkTotal = 17; + string rule = 18; + string outbound = 19; + string outboundType = 20; + repeated string chainList = 21; + ProcessInfo processInfo = 22; +} + +message ProcessInfo { + uint32 processId = 1; + int32 userId = 2; + string userName = 3; + string processPath = 4; + repeated string packageNames = 5; +} + +message CloseConnectionRequest { + string id = 1; +} + +message DeprecatedWarnings { + repeated DeprecatedWarning warnings = 1; +} + +message DeprecatedWarning { + string message = 1; + bool impending = 2; + string migrationLink = 3; + string description = 4; + string deprecatedVersion = 5; + string scheduledVersion = 6; +} + +message StartedAt { + int64 startedAt = 1; +} + +message OutboundList { + repeated GroupItem outbounds = 1; +} + +message NetworkQualityTestRequest { + string configURL = 1; + string outboundTag = 2; + bool serial = 3; + int32 maxRuntimeSeconds = 4; + bool http3 = 5; +} + +message NetworkQualityTestProgress { + int32 phase = 1; + int64 downloadCapacity = 2; + int64 uploadCapacity = 3; + int32 downloadRPM = 4; + int32 uploadRPM = 5; + int32 idleLatencyMs = 6; + int64 elapsedMs = 7; + bool isFinal = 8; + string error = 9; + int32 downloadCapacityAccuracy = 10; + int32 uploadCapacityAccuracy = 11; + int32 downloadRPMAccuracy = 12; + int32 uploadRPMAccuracy = 13; +} + +message STUNTestRequest { + string server = 1; + string outboundTag = 2; +} + +message STUNTestProgress { + int32 phase = 1; + string externalAddr = 2; + int32 latencyMs = 3; + int32 natMapping = 4; + int32 natFiltering = 5; + bool isFinal = 6; + string error = 7; + bool natTypeSupported = 8; +} + +message TailscaleStatusUpdate { + repeated TailscaleEndpointStatus endpoints = 1; +} + +message TailscaleEndpointStatus { + string endpointTag = 1; + string backendState = 2; + string authURL = 3; + string networkName = 4; + string magicDNSSuffix = 5; + TailscalePeer self = 6; + repeated TailscaleUserGroup userGroups = 7; +} + +message TailscaleUserGroup { + int64 userID = 1; + string loginName = 2; + string displayName = 3; + string profilePicURL = 4; + repeated TailscalePeer peers = 5; +} + +message TailscalePeer { + string hostName = 1; + string dnsName = 2; + string os = 3; + repeated string tailscaleIPs = 4; + bool online = 5; + bool exitNode = 6; + bool exitNodeOption = 7; + bool active = 8; + int64 rxBytes = 9; + int64 txBytes = 10; + int64 keyExpiry = 11; +} + +message TailscalePingRequest { + string endpointTag = 1; + string peerIP = 2; +} + +message TailscalePingResponse { + double latencyMs = 1; + bool isDirect = 2; + string endpoint = 3; + int32 derpRegionID = 4; + string derpRegionCode = 5; + string error = 6; +} diff --git a/daemon/started_service_grpc.pb.go b/daemon/started_service_grpc.pb.go new file mode 100644 index 00000000..967757f1 --- /dev/null +++ b/daemon/started_service_grpc.pb.go @@ -0,0 +1,1204 @@ +package daemon + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + StartedService_StopService_FullMethodName = "/daemon.StartedService/StopService" + StartedService_ReloadService_FullMethodName = "/daemon.StartedService/ReloadService" + StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus" + StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog" + StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel" + StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs" + StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus" + StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups" + StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus" + StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode" + StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode" + StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest" + StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound" + StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand" + StartedService_GetSystemProxyStatus_FullMethodName = "/daemon.StartedService/GetSystemProxyStatus" + StartedService_SetSystemProxyEnabled_FullMethodName = "/daemon.StartedService/SetSystemProxyEnabled" + StartedService_TriggerDebugCrash_FullMethodName = "/daemon.StartedService/TriggerDebugCrash" + StartedService_TriggerOOMReport_FullMethodName = "/daemon.StartedService/TriggerOOMReport" + StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections" + StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection" + StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections" + StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings" + StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt" + StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds" + StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest" + StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest" + StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus" + StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing" +) + +// StartedServiceClient is the client API for StartedService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type StartedServiceClient interface { + StopService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + ReloadService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + SubscribeServiceStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ServiceStatus], error) + SubscribeLog(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Log], error) + GetDefaultLogLevel(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DefaultLogLevel, error) + ClearLogs(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + SubscribeStatus(ctx context.Context, in *SubscribeStatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Status], error) + SubscribeGroups(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Groups], error) + GetClashModeStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ClashModeStatus, error) + SubscribeClashMode(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ClashMode], error) + SetClashMode(ctx context.Context, in *ClashMode, opts ...grpc.CallOption) (*emptypb.Empty, error) + URLTest(ctx context.Context, in *URLTestRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SelectOutbound(ctx context.Context, in *SelectOutboundRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SetGroupExpand(ctx context.Context, in *SetGroupExpandRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetSystemProxyStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SystemProxyStatus, error) + SetSystemProxyEnabled(ctx context.Context, in *SetSystemProxyEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + TriggerDebugCrash(ctx context.Context, in *DebugCrashRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + TriggerOOMReport(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + SubscribeConnections(ctx context.Context, in *SubscribeConnectionsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ConnectionEvents], error) + CloseConnection(ctx context.Context, in *CloseConnectionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + CloseAllConnections(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetDeprecatedWarnings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DeprecatedWarnings, error) + GetStartedAt(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StartedAt, error) + SubscribeOutbounds(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OutboundList], error) + StartNetworkQualityTest(ctx context.Context, in *NetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NetworkQualityTestProgress], error) + StartSTUNTest(ctx context.Context, in *STUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[STUNTestProgress], error) + SubscribeTailscaleStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscaleStatusUpdate], error) + StartTailscalePing(ctx context.Context, in *TailscalePingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscalePingResponse], error) +} + +type startedServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewStartedServiceClient(cc grpc.ClientConnInterface) StartedServiceClient { + return &startedServiceClient{cc} +} + +func (c *startedServiceClient) StopService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_StopService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) ReloadService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_ReloadService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeServiceStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ServiceStatus], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[0], StartedService_SubscribeServiceStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, ServiceStatus]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeServiceStatusClient = grpc.ServerStreamingClient[ServiceStatus] + +func (c *startedServiceClient) SubscribeLog(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Log], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[1], StartedService_SubscribeLog_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, Log]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeLogClient = grpc.ServerStreamingClient[Log] + +func (c *startedServiceClient) GetDefaultLogLevel(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DefaultLogLevel, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DefaultLogLevel) + err := c.cc.Invoke(ctx, StartedService_GetDefaultLogLevel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) ClearLogs(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_ClearLogs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeStatus(ctx context.Context, in *SubscribeStatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Status], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[2], StartedService_SubscribeStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeStatusRequest, Status]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeStatusClient = grpc.ServerStreamingClient[Status] + +func (c *startedServiceClient) SubscribeGroups(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Groups], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[3], StartedService_SubscribeGroups_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, Groups]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeGroupsClient = grpc.ServerStreamingClient[Groups] + +func (c *startedServiceClient) GetClashModeStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ClashModeStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClashModeStatus) + err := c.cc.Invoke(ctx, StartedService_GetClashModeStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeClashMode(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ClashMode], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[4], StartedService_SubscribeClashMode_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, ClashMode]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeClashModeClient = grpc.ServerStreamingClient[ClashMode] + +func (c *startedServiceClient) SetClashMode(ctx context.Context, in *ClashMode, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SetClashMode_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) URLTest(ctx context.Context, in *URLTestRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_URLTest_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SelectOutbound(ctx context.Context, in *SelectOutboundRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SelectOutbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SetGroupExpand(ctx context.Context, in *SetGroupExpandRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SetGroupExpand_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) GetSystemProxyStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SystemProxyStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SystemProxyStatus) + err := c.cc.Invoke(ctx, StartedService_GetSystemProxyStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SetSystemProxyEnabled(ctx context.Context, in *SetSystemProxyEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SetSystemProxyEnabled_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) TriggerDebugCrash(ctx context.Context, in *DebugCrashRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_TriggerDebugCrash_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) TriggerOOMReport(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_TriggerOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeConnections(ctx context.Context, in *SubscribeConnectionsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ConnectionEvents], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[5], StartedService_SubscribeConnections_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeConnectionsRequest, ConnectionEvents]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeConnectionsClient = grpc.ServerStreamingClient[ConnectionEvents] + +func (c *startedServiceClient) CloseConnection(ctx context.Context, in *CloseConnectionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_CloseConnection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) CloseAllConnections(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_CloseAllConnections_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) GetDeprecatedWarnings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DeprecatedWarnings, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeprecatedWarnings) + err := c.cc.Invoke(ctx, StartedService_GetDeprecatedWarnings_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) GetStartedAt(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StartedAt, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StartedAt) + err := c.cc.Invoke(ctx, StartedService_GetStartedAt_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeOutbounds(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OutboundList], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[6], StartedService_SubscribeOutbounds_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, OutboundList]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOutboundsClient = grpc.ServerStreamingClient[OutboundList] + +func (c *startedServiceClient) StartNetworkQualityTest(ctx context.Context, in *NetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NetworkQualityTestProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[7], StartedService_StartNetworkQualityTest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[NetworkQualityTestRequest, NetworkQualityTestProgress]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_StartNetworkQualityTestClient = grpc.ServerStreamingClient[NetworkQualityTestProgress] + +func (c *startedServiceClient) StartSTUNTest(ctx context.Context, in *STUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[STUNTestProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[8], StartedService_StartSTUNTest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[STUNTestRequest, STUNTestProgress]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_StartSTUNTestClient = grpc.ServerStreamingClient[STUNTestProgress] + +func (c *startedServiceClient) SubscribeTailscaleStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscaleStatusUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[9], StartedService_SubscribeTailscaleStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, TailscaleStatusUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeTailscaleStatusClient = grpc.ServerStreamingClient[TailscaleStatusUpdate] + +func (c *startedServiceClient) StartTailscalePing(ctx context.Context, in *TailscalePingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscalePingResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[10], StartedService_StartTailscalePing_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[TailscalePingRequest, TailscalePingResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_StartTailscalePingClient = grpc.ServerStreamingClient[TailscalePingResponse] + +// StartedServiceServer is the server API for StartedService service. +// All implementations must embed UnimplementedStartedServiceServer +// for forward compatibility. +type StartedServiceServer interface { + StopService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + ReloadService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + SubscribeServiceStatus(*emptypb.Empty, grpc.ServerStreamingServer[ServiceStatus]) error + SubscribeLog(*emptypb.Empty, grpc.ServerStreamingServer[Log]) error + GetDefaultLogLevel(context.Context, *emptypb.Empty) (*DefaultLogLevel, error) + ClearLogs(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + SubscribeStatus(*SubscribeStatusRequest, grpc.ServerStreamingServer[Status]) error + SubscribeGroups(*emptypb.Empty, grpc.ServerStreamingServer[Groups]) error + GetClashModeStatus(context.Context, *emptypb.Empty) (*ClashModeStatus, error) + SubscribeClashMode(*emptypb.Empty, grpc.ServerStreamingServer[ClashMode]) error + SetClashMode(context.Context, *ClashMode) (*emptypb.Empty, error) + URLTest(context.Context, *URLTestRequest) (*emptypb.Empty, error) + SelectOutbound(context.Context, *SelectOutboundRequest) (*emptypb.Empty, error) + SetGroupExpand(context.Context, *SetGroupExpandRequest) (*emptypb.Empty, error) + GetSystemProxyStatus(context.Context, *emptypb.Empty) (*SystemProxyStatus, error) + SetSystemProxyEnabled(context.Context, *SetSystemProxyEnabledRequest) (*emptypb.Empty, error) + TriggerDebugCrash(context.Context, *DebugCrashRequest) (*emptypb.Empty, error) + TriggerOOMReport(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + SubscribeConnections(*SubscribeConnectionsRequest, grpc.ServerStreamingServer[ConnectionEvents]) error + CloseConnection(context.Context, *CloseConnectionRequest) (*emptypb.Empty, error) + CloseAllConnections(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + GetDeprecatedWarnings(context.Context, *emptypb.Empty) (*DeprecatedWarnings, error) + GetStartedAt(context.Context, *emptypb.Empty) (*StartedAt, error) + SubscribeOutbounds(*emptypb.Empty, grpc.ServerStreamingServer[OutboundList]) error + StartNetworkQualityTest(*NetworkQualityTestRequest, grpc.ServerStreamingServer[NetworkQualityTestProgress]) error + StartSTUNTest(*STUNTestRequest, grpc.ServerStreamingServer[STUNTestProgress]) error + SubscribeTailscaleStatus(*emptypb.Empty, grpc.ServerStreamingServer[TailscaleStatusUpdate]) error + StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error + mustEmbedUnimplementedStartedServiceServer() +} + +// UnimplementedStartedServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedStartedServiceServer struct{} + +func (UnimplementedStartedServiceServer) StopService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method StopService not implemented") +} + +func (UnimplementedStartedServiceServer) ReloadService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ReloadService not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeServiceStatus(*emptypb.Empty, grpc.ServerStreamingServer[ServiceStatus]) error { + return status.Error(codes.Unimplemented, "method SubscribeServiceStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeLog(*emptypb.Empty, grpc.ServerStreamingServer[Log]) error { + return status.Error(codes.Unimplemented, "method SubscribeLog not implemented") +} + +func (UnimplementedStartedServiceServer) GetDefaultLogLevel(context.Context, *emptypb.Empty) (*DefaultLogLevel, error) { + return nil, status.Error(codes.Unimplemented, "method GetDefaultLogLevel not implemented") +} + +func (UnimplementedStartedServiceServer) ClearLogs(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ClearLogs not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeStatus(*SubscribeStatusRequest, grpc.ServerStreamingServer[Status]) error { + return status.Error(codes.Unimplemented, "method SubscribeStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeGroups(*emptypb.Empty, grpc.ServerStreamingServer[Groups]) error { + return status.Error(codes.Unimplemented, "method SubscribeGroups not implemented") +} + +func (UnimplementedStartedServiceServer) GetClashModeStatus(context.Context, *emptypb.Empty) (*ClashModeStatus, error) { + return nil, status.Error(codes.Unimplemented, "method GetClashModeStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeClashMode(*emptypb.Empty, grpc.ServerStreamingServer[ClashMode]) error { + return status.Error(codes.Unimplemented, "method SubscribeClashMode not implemented") +} + +func (UnimplementedStartedServiceServer) SetClashMode(context.Context, *ClashMode) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SetClashMode not implemented") +} + +func (UnimplementedStartedServiceServer) URLTest(context.Context, *URLTestRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method URLTest not implemented") +} + +func (UnimplementedStartedServiceServer) SelectOutbound(context.Context, *SelectOutboundRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SelectOutbound not implemented") +} + +func (UnimplementedStartedServiceServer) SetGroupExpand(context.Context, *SetGroupExpandRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SetGroupExpand not implemented") +} + +func (UnimplementedStartedServiceServer) GetSystemProxyStatus(context.Context, *emptypb.Empty) (*SystemProxyStatus, error) { + return nil, status.Error(codes.Unimplemented, "method GetSystemProxyStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SetSystemProxyEnabled(context.Context, *SetSystemProxyEnabledRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SetSystemProxyEnabled not implemented") +} + +func (UnimplementedStartedServiceServer) TriggerDebugCrash(context.Context, *DebugCrashRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method TriggerDebugCrash not implemented") +} + +func (UnimplementedStartedServiceServer) TriggerOOMReport(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method TriggerOOMReport not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeConnections(*SubscribeConnectionsRequest, grpc.ServerStreamingServer[ConnectionEvents]) error { + return status.Error(codes.Unimplemented, "method SubscribeConnections not implemented") +} + +func (UnimplementedStartedServiceServer) CloseConnection(context.Context, *CloseConnectionRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CloseConnection not implemented") +} + +func (UnimplementedStartedServiceServer) CloseAllConnections(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CloseAllConnections not implemented") +} + +func (UnimplementedStartedServiceServer) GetDeprecatedWarnings(context.Context, *emptypb.Empty) (*DeprecatedWarnings, error) { + return nil, status.Error(codes.Unimplemented, "method GetDeprecatedWarnings not implemented") +} + +func (UnimplementedStartedServiceServer) GetStartedAt(context.Context, *emptypb.Empty) (*StartedAt, error) { + return nil, status.Error(codes.Unimplemented, "method GetStartedAt not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeOutbounds(*emptypb.Empty, grpc.ServerStreamingServer[OutboundList]) error { + return status.Error(codes.Unimplemented, "method SubscribeOutbounds not implemented") +} + +func (UnimplementedStartedServiceServer) StartNetworkQualityTest(*NetworkQualityTestRequest, grpc.ServerStreamingServer[NetworkQualityTestProgress]) error { + return status.Error(codes.Unimplemented, "method StartNetworkQualityTest not implemented") +} + +func (UnimplementedStartedServiceServer) StartSTUNTest(*STUNTestRequest, grpc.ServerStreamingServer[STUNTestProgress]) error { + return status.Error(codes.Unimplemented, "method StartSTUNTest not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeTailscaleStatus(*emptypb.Empty, grpc.ServerStreamingServer[TailscaleStatusUpdate]) error { + return status.Error(codes.Unimplemented, "method SubscribeTailscaleStatus not implemented") +} + +func (UnimplementedStartedServiceServer) StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error { + return status.Error(codes.Unimplemented, "method StartTailscalePing not implemented") +} +func (UnimplementedStartedServiceServer) mustEmbedUnimplementedStartedServiceServer() {} +func (UnimplementedStartedServiceServer) testEmbeddedByValue() {} + +// UnsafeStartedServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StartedServiceServer will +// result in compilation errors. +type UnsafeStartedServiceServer interface { + mustEmbedUnimplementedStartedServiceServer() +} + +func RegisterStartedServiceServer(s grpc.ServiceRegistrar, srv StartedServiceServer) { + // If the following call panics, it indicates UnimplementedStartedServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&StartedService_ServiceDesc, srv) +} + +func _StartedService_StopService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).StopService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_StopService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).StopService(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_ReloadService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).ReloadService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_ReloadService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).ReloadService(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeServiceStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeServiceStatus(m, &grpc.GenericServerStream[emptypb.Empty, ServiceStatus]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeServiceStatusServer = grpc.ServerStreamingServer[ServiceStatus] + +func _StartedService_SubscribeLog_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeLog(m, &grpc.GenericServerStream[emptypb.Empty, Log]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeLogServer = grpc.ServerStreamingServer[Log] + +func _StartedService_GetDefaultLogLevel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).GetDefaultLogLevel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_GetDefaultLogLevel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).GetDefaultLogLevel(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_ClearLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).ClearLogs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_ClearLogs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).ClearLogs(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SubscribeStatusRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeStatus(m, &grpc.GenericServerStream[SubscribeStatusRequest, Status]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeStatusServer = grpc.ServerStreamingServer[Status] + +func _StartedService_SubscribeGroups_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeGroups(m, &grpc.GenericServerStream[emptypb.Empty, Groups]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeGroupsServer = grpc.ServerStreamingServer[Groups] + +func _StartedService_GetClashModeStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).GetClashModeStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_GetClashModeStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).GetClashModeStatus(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeClashMode_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeClashMode(m, &grpc.GenericServerStream[emptypb.Empty, ClashMode]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeClashModeServer = grpc.ServerStreamingServer[ClashMode] + +func _StartedService_SetClashMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClashMode) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SetClashMode(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SetClashMode_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SetClashMode(ctx, req.(*ClashMode)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_URLTest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(URLTestRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).URLTest(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_URLTest_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).URLTest(ctx, req.(*URLTestRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SelectOutbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SelectOutboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SelectOutbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SelectOutbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SelectOutbound(ctx, req.(*SelectOutboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SetGroupExpand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetGroupExpandRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SetGroupExpand(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SetGroupExpand_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SetGroupExpand(ctx, req.(*SetGroupExpandRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_GetSystemProxyStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).GetSystemProxyStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_GetSystemProxyStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).GetSystemProxyStatus(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SetSystemProxyEnabled_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetSystemProxyEnabledRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SetSystemProxyEnabled(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SetSystemProxyEnabled_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SetSystemProxyEnabled(ctx, req.(*SetSystemProxyEnabledRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_TriggerDebugCrash_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DebugCrashRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).TriggerDebugCrash(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_TriggerDebugCrash_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).TriggerDebugCrash(ctx, req.(*DebugCrashRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_TriggerOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).TriggerOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_TriggerOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).TriggerOOMReport(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeConnections_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SubscribeConnectionsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeConnections(m, &grpc.GenericServerStream[SubscribeConnectionsRequest, ConnectionEvents]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeConnectionsServer = grpc.ServerStreamingServer[ConnectionEvents] + +func _StartedService_CloseConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CloseConnectionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).CloseConnection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_CloseConnection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).CloseConnection(ctx, req.(*CloseConnectionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_CloseAllConnections_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).CloseAllConnections(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_CloseAllConnections_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).CloseAllConnections(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_GetDeprecatedWarnings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).GetDeprecatedWarnings(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_GetDeprecatedWarnings_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).GetDeprecatedWarnings(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_GetStartedAt_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).GetStartedAt(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_GetStartedAt_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).GetStartedAt(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeOutbounds_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeOutbounds(m, &grpc.GenericServerStream[emptypb.Empty, OutboundList]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOutboundsServer = grpc.ServerStreamingServer[OutboundList] + +func _StartedService_StartNetworkQualityTest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(NetworkQualityTestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).StartNetworkQualityTest(m, &grpc.GenericServerStream[NetworkQualityTestRequest, NetworkQualityTestProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_StartNetworkQualityTestServer = grpc.ServerStreamingServer[NetworkQualityTestProgress] + +func _StartedService_StartSTUNTest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(STUNTestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).StartSTUNTest(m, &grpc.GenericServerStream[STUNTestRequest, STUNTestProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_StartSTUNTestServer = grpc.ServerStreamingServer[STUNTestProgress] + +func _StartedService_SubscribeTailscaleStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeTailscaleStatus(m, &grpc.GenericServerStream[emptypb.Empty, TailscaleStatusUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeTailscaleStatusServer = grpc.ServerStreamingServer[TailscaleStatusUpdate] + +func _StartedService_StartTailscalePing_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(TailscalePingRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).StartTailscalePing(m, &grpc.GenericServerStream[TailscalePingRequest, TailscalePingResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_StartTailscalePingServer = grpc.ServerStreamingServer[TailscalePingResponse] + +// StartedService_ServiceDesc is the grpc.ServiceDesc for StartedService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StartedService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "daemon.StartedService", + HandlerType: (*StartedServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "StopService", + Handler: _StartedService_StopService_Handler, + }, + { + MethodName: "ReloadService", + Handler: _StartedService_ReloadService_Handler, + }, + { + MethodName: "GetDefaultLogLevel", + Handler: _StartedService_GetDefaultLogLevel_Handler, + }, + { + MethodName: "ClearLogs", + Handler: _StartedService_ClearLogs_Handler, + }, + { + MethodName: "GetClashModeStatus", + Handler: _StartedService_GetClashModeStatus_Handler, + }, + { + MethodName: "SetClashMode", + Handler: _StartedService_SetClashMode_Handler, + }, + { + MethodName: "URLTest", + Handler: _StartedService_URLTest_Handler, + }, + { + MethodName: "SelectOutbound", + Handler: _StartedService_SelectOutbound_Handler, + }, + { + MethodName: "SetGroupExpand", + Handler: _StartedService_SetGroupExpand_Handler, + }, + { + MethodName: "GetSystemProxyStatus", + Handler: _StartedService_GetSystemProxyStatus_Handler, + }, + { + MethodName: "SetSystemProxyEnabled", + Handler: _StartedService_SetSystemProxyEnabled_Handler, + }, + { + MethodName: "TriggerDebugCrash", + Handler: _StartedService_TriggerDebugCrash_Handler, + }, + { + MethodName: "TriggerOOMReport", + Handler: _StartedService_TriggerOOMReport_Handler, + }, + { + MethodName: "CloseConnection", + Handler: _StartedService_CloseConnection_Handler, + }, + { + MethodName: "CloseAllConnections", + Handler: _StartedService_CloseAllConnections_Handler, + }, + { + MethodName: "GetDeprecatedWarnings", + Handler: _StartedService_GetDeprecatedWarnings_Handler, + }, + { + MethodName: "GetStartedAt", + Handler: _StartedService_GetStartedAt_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "SubscribeServiceStatus", + Handler: _StartedService_SubscribeServiceStatus_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeLog", + Handler: _StartedService_SubscribeLog_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeStatus", + Handler: _StartedService_SubscribeStatus_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeGroups", + Handler: _StartedService_SubscribeGroups_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeClashMode", + Handler: _StartedService_SubscribeClashMode_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeConnections", + Handler: _StartedService_SubscribeConnections_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeOutbounds", + Handler: _StartedService_SubscribeOutbounds_Handler, + ServerStreams: true, + }, + { + StreamName: "StartNetworkQualityTest", + Handler: _StartedService_StartNetworkQualityTest_Handler, + ServerStreams: true, + }, + { + StreamName: "StartSTUNTest", + Handler: _StartedService_StartSTUNTest_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeTailscaleStatus", + Handler: _StartedService_SubscribeTailscaleStatus_Handler, + ServerStreams: true, + }, + { + StreamName: "StartTailscalePing", + Handler: _StartedService_StartTailscalePing_Handler, + ServerStreams: true, + }, + }, + Metadata: "daemon/started_service.proto", +} diff --git a/debug.go b/debug.go new file mode 100644 index 00000000..f620172b --- /dev/null +++ b/debug.go @@ -0,0 +1,34 @@ +package box + +import ( + "runtime/debug" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func applyDebugOptions(options option.DebugOptions) error { + applyDebugListenOption(options) + if options.GCPercent != nil { + debug.SetGCPercent(*options.GCPercent) + } + if options.MaxStack != nil { + debug.SetMaxStack(*options.MaxStack) + } + if options.MaxThreads != nil { + debug.SetMaxThreads(*options.MaxThreads) + } + if options.PanicOnFault != nil { + debug.SetPanicOnFault(*options.PanicOnFault) + } + if options.TraceBack != "" { + debug.SetTraceback(options.TraceBack) + } + if options.MemoryLimit.Value() != 0 { + debug.SetMemoryLimit(int64(float64(options.MemoryLimit.Value()) / 1.5)) + } + if options.OOMKiller != nil { + return E.New("legacy oom_killer in debug options is removed, use oom-killer service instead") + } + return nil +} diff --git a/debug_http.go b/debug_http.go new file mode 100644 index 00000000..e51a0731 --- /dev/null +++ b/debug_http.go @@ -0,0 +1,76 @@ +package box + +import ( + "net/http" + "net/http/pprof" + "runtime" + "runtime/debug" + "strings" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + + "github.com/go-chi/chi/v5" +) + +var debugHTTPServer *http.Server + +func applyDebugListenOption(options option.DebugOptions) { + if debugHTTPServer != nil { + debugHTTPServer.Close() + debugHTTPServer = nil + } + if options.Listen == "" { + return + } + r := chi.NewMux() + r.Route("/debug", func(r chi.Router) { + r.Get("/gc", func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNoContent) + go debug.FreeOSMemory() + }) + r.Get("/memory", func(writer http.ResponseWriter, request *http.Request) { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + var memObject badjson.JSONObject + memObject.Put("heap", byteformats.FormatMemoryBytes(memStats.HeapInuse)) + memObject.Put("stack", byteformats.FormatMemoryBytes(memStats.StackInuse)) + memObject.Put("idle", byteformats.FormatMemoryBytes(memStats.HeapIdle-memStats.HeapReleased)) + memObject.Put("goroutines", runtime.NumGoroutine()) + memObject.Put("rss", rusageMaxRSS()) + + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + encoder.Encode(&memObject) + }) + r.Route("/pprof", func(r chi.Router) { + r.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + if !strings.HasSuffix(request.URL.Path, "/") { + http.Redirect(writer, request, request.URL.Path+"/", http.StatusMovedPermanently) + } else { + pprof.Index(writer, request) + } + }) + r.HandleFunc("/*", pprof.Index) + r.HandleFunc("/cmdline", pprof.Cmdline) + r.HandleFunc("/profile", pprof.Profile) + r.HandleFunc("/symbol", pprof.Symbol) + r.HandleFunc("/trace", pprof.Trace) + }) + }) + debugHTTPServer = &http.Server{ + Addr: options.Listen, + Handler: r, + } + go func() { + err := debugHTTPServer.ListenAndServe() + if err != nil && !E.IsClosed(err) { + log.Error(E.Cause(err, "serve debug HTTP server")) + } + }() +} diff --git a/debug_stub.go b/debug_stub.go new file mode 100644 index 00000000..a8988c20 --- /dev/null +++ b/debug_stub.go @@ -0,0 +1,7 @@ +//go:build !(linux || darwin) + +package box + +func rusageMaxRSS() float64 { + return -1 +} diff --git a/debug_unix.go b/debug_unix.go new file mode 100644 index 00000000..3be097e9 --- /dev/null +++ b/debug_unix.go @@ -0,0 +1,25 @@ +//go:build linux || darwin + +package box + +import ( + "runtime" + "syscall" +) + +func rusageMaxRSS() float64 { + ru := syscall.Rusage{} + err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru) + if err != nil { + return 0 + } + + rss := float64(ru.Maxrss) + if runtime.GOOS == "darwin" || runtime.GOOS == "ios" { + rss /= 1 << 20 // ru_maxrss is bytes on darwin + } else { + // ru_maxrss is kilobytes elsewhere (linux, openbsd, etc) + rss /= 1 << 10 + } + return rss +} diff --git a/dns/client.go b/dns/client.go new file mode 100644 index 00000000..37ba98a8 --- /dev/null +++ b/dns/client.go @@ -0,0 +1,709 @@ +package dns + +import ( + "context" + "errors" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/compatible" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" + + "github.com/miekg/dns" +) + +var ( + ErrNoRawSupport = E.New("no raw query support by current transport") + ErrNotCached = E.New("not cached") + ErrResponseRejected = E.New("response rejected") + ErrResponseRejectedCached = E.Extend(ErrResponseRejected, "cached") +) + +var _ adapter.DNSClient = (*Client)(nil) + +type Client struct { + ctx context.Context + timeout time.Duration + disableCache bool + disableExpire bool + optimisticTimeout time.Duration + cacheCapacity uint32 + clientSubnet netip.Prefix + rdrc adapter.RDRCStore + initRDRCFunc func() adapter.RDRCStore + dnsCache adapter.DNSCacheStore + initDNSCacheFunc func() adapter.DNSCacheStore + logger logger.ContextLogger + cache freelru.Cache[dnsCacheKey, *dns.Msg] + cacheLock compatible.Map[dnsCacheKey, chan struct{}] + backgroundRefresh compatible.Map[dnsCacheKey, struct{}] +} + +type ClientOptions struct { + Context context.Context + Timeout time.Duration + DisableCache bool + DisableExpire bool + OptimisticTimeout time.Duration + CacheCapacity uint32 + ClientSubnet netip.Prefix + RDRC func() adapter.RDRCStore + DNSCache func() adapter.DNSCacheStore + Logger logger.ContextLogger +} + +func NewClient(options ClientOptions) *Client { + cacheCapacity := options.CacheCapacity + if cacheCapacity < 1024 { + cacheCapacity = 1024 + } + client := &Client{ + ctx: options.Context, + timeout: options.Timeout, + disableCache: options.DisableCache, + disableExpire: options.DisableExpire, + optimisticTimeout: options.OptimisticTimeout, + cacheCapacity: cacheCapacity, + clientSubnet: options.ClientSubnet, + initRDRCFunc: options.RDRC, + initDNSCacheFunc: options.DNSCache, + logger: options.Logger, + } + if client.timeout == 0 { + client.timeout = C.DNSTimeout + } + if !client.disableCache && client.initDNSCacheFunc == nil { + client.initializeMemoryCache() + } + return client +} + +type dnsCacheKey struct { + dns.Question + transportTag string +} + +func (c *Client) Start() { + if c.initRDRCFunc != nil { + c.rdrc = c.initRDRCFunc() + } + if c.initDNSCacheFunc != nil { + c.dnsCache = c.initDNSCacheFunc() + } + if c.dnsCache == nil { + c.initializeMemoryCache() + } +} + +func (c *Client) initializeMemoryCache() { + if c.disableCache || c.cache != nil { + return + } + c.cache = common.Must1(freelru.NewSharded[dnsCacheKey, *dns.Msg](c.cacheCapacity, maphash.NewHasher[dnsCacheKey]().Hash32)) +} + +func extractNegativeTTL(response *dns.Msg) (uint32, bool) { + for _, record := range response.Ns { + if soa, isSOA := record.(*dns.SOA); isSOA { + soaTTL := soa.Header().Ttl + soaMinimum := soa.Minttl + if soaTTL < soaMinimum { + return soaTTL, true + } + return soaMinimum, true + } + } + return 0, false +} + +func computeTimeToLive(response *dns.Msg) uint32 { + var timeToLive uint32 + if len(response.Answer) == 0 { + if soaTTL, hasSOA := extractNegativeTTL(response); hasSOA { + return soaTTL + } + } + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } + if timeToLive == 0 || record.Header().Ttl > 0 && record.Header().Ttl < timeToLive { + timeToLive = record.Header().Ttl + } + } + } + return timeToLive +} + +func normalizeTTL(response *dns.Msg, timeToLive uint32) { + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } + record.Header().Ttl = timeToLive + } + } +} + +func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { + if len(message.Question) == 0 { + if c.logger != nil { + c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) + } + return FixedResponseStatus(message, dns.RcodeFormatError), nil + } + question := message.Question[0] + if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only || question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only { + if c.logger != nil { + c.logger.DebugContext(ctx, "strategy rejected") + } + return FixedResponseStatus(message, dns.RcodeSuccess), nil + } + message = c.prepareExchangeMessage(message, options) + + isSimpleRequest := len(message.Question) == 1 && + len(message.Ns) == 0 && + (len(message.Extra) == 0 || len(message.Extra) == 1 && + message.Extra[0].Header().Rrtype == dns.TypeOPT && + message.Extra[0].Header().Class > 0 && + message.Extra[0].Header().Ttl == 0 && + len(message.Extra[0].(*dns.OPT).Option) == 0) && + !options.ClientSubnet.IsValid() + disableCache := !isSimpleRequest || c.disableCache || options.DisableCache + if !disableCache { + cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag()} + cond, loaded := c.cacheLock.LoadOrStore(cacheKey, make(chan struct{})) + if loaded { + select { + case <-cond: + case <-ctx.Done(): + return nil, ctx.Err() + } + } else { + defer func() { + c.cacheLock.Delete(cacheKey) + close(cond) + }() + } + response, ttl, isStale := c.loadResponse(question, transport) + if response != nil { + if isStale && !options.DisableOptimisticCache { + c.backgroundRefreshDNS(transport, question, message.Copy(), options, responseChecker) + logOptimisticResponse(c.logger, ctx, response) + response.Id = message.Id + return response, nil + } else if !isStale { + logCachedResponse(c.logger, ctx, response, ttl) + response.Id = message.Id + return response, nil + } + } + } + + messageId := message.Id + contextTransport, clientSubnetLoaded := transportTagFromContext(ctx) + if clientSubnetLoaded && transport.Tag() == contextTransport { + return nil, E.New("DNS query loopback in transport[", contextTransport, "]") + } + ctx = contextWithTransportTag(ctx, transport.Tag()) + if !disableCache && responseChecker != nil && c.rdrc != nil { + rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype) + if rejected { + return nil, ErrResponseRejectedCached + } + } + response, err := c.exchangeToTransport(ctx, transport, message) + if err != nil { + return nil, err + } + disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError) + if responseChecker != nil { + var rejected bool + if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { + rejected = true + } else { + rejected = !responseChecker(response) + } + if rejected { + if !disableCache && c.rdrc != nil { + c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger) + } + logRejectedResponse(c.logger, ctx, response) + return response, ErrResponseRejected + } + } + timeToLive := applyResponseOptions(question, response, options) + if !disableCache { + c.storeCache(transport, question, response, timeToLive) + } + response.Id = messageId + requestEDNSOpt := message.IsEdns0() + responseEDNSOpt := response.IsEdns0() + if responseEDNSOpt != nil && (requestEDNSOpt == nil || requestEDNSOpt.Version() < responseEDNSOpt.Version()) { + response.Extra = common.Filter(response.Extra, func(it dns.RR) bool { + return it.Header().Rrtype != dns.TypeOPT + }) + if requestEDNSOpt != nil { + response.SetEdns0(responseEDNSOpt.UDPSize(), responseEDNSOpt.Do()) + } + } + logExchangedResponse(c.logger, ctx, response, timeToLive) + return response, nil +} + +func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { + domain = FqdnToDomain(domain) + dnsName := dns.Fqdn(domain) + var strategy C.DomainStrategy + if options.LookupStrategy != C.DomainStrategyAsIS { + strategy = options.LookupStrategy + } else { + strategy = options.Strategy + } + lookupOptions := options + if options.LookupStrategy != C.DomainStrategyAsIS { + lookupOptions.Strategy = strategy + } + if strategy == C.DomainStrategyIPv4Only { + return c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, lookupOptions, responseChecker) + } else if strategy == C.DomainStrategyIPv6Only { + return c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, lookupOptions, responseChecker) + } + var response4 []netip.Addr + var response6 []netip.Addr + var group task.Group + group.Append("exchange4", func(ctx context.Context) error { + response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, lookupOptions, responseChecker) + if err != nil { + return err + } + response4 = response + return nil + }) + group.Append("exchange6", func(ctx context.Context) error { + response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, lookupOptions, responseChecker) + if err != nil { + return err + } + response6 = response + return nil + }) + err := group.Run(ctx) + if len(response4) == 0 && len(response6) == 0 { + return nil, err + } + return sortAddresses(response4, response6, strategy), nil +} + +func (c *Client) ClearCache() { + if c.cache != nil { + c.cache.Purge() + } + if c.dnsCache != nil { + err := c.dnsCache.ClearDNSCache() + if err != nil && c.logger != nil { + c.logger.Warn("clear DNS cache: ", err) + } + } +} + +func sortAddresses(response4 []netip.Addr, response6 []netip.Addr, strategy C.DomainStrategy) []netip.Addr { + if strategy == C.DomainStrategyPreferIPv6 { + return append(response6, response4...) + } else { + return append(response4, response6...) + } +} + +func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32) { + if timeToLive == 0 { + return + } + if c.dnsCache != nil { + packed, err := message.Pack() + if err == nil { + expireAt := time.Now().Add(time.Second * time.Duration(timeToLive)) + c.dnsCache.SaveDNSCacheAsync(transport.Tag(), question.Name, question.Qtype, packed, expireAt, c.logger) + } + return + } + if c.cache == nil { + return + } + key := dnsCacheKey{Question: question, transportTag: transport.Tag()} + if c.disableExpire { + c.cache.Add(key, message.Copy()) + } else { + c.cache.AddWithLifetime(key, message.Copy(), time.Second*time.Duration(timeToLive)) + } +} + +func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { + question := dns.Question{ + Name: name, + Qtype: qType, + Qclass: dns.ClassINET, + } + message := dns.Msg{ + MsgHdr: dns.MsgHdr{ + RecursionDesired: true, + }, + Question: []dns.Question{question}, + } + disableCache := c.disableCache || options.DisableCache + if !disableCache { + cachedAddresses, err := c.questionCache(ctx, transport, &message, options, responseChecker) + if err != ErrNotCached { + return cachedAddresses, err + } + } + response, err := c.Exchange(ctx, transport, &message, options, responseChecker) + if err != nil { + return nil, err + } + if response.Rcode != dns.RcodeSuccess { + return nil, RcodeError(response.Rcode) + } + return MessageToAddresses(response), nil +} + +func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { + question := message.Question[0] + response, _, isStale := c.loadResponse(question, transport) + if response == nil { + return nil, ErrNotCached + } + if isStale { + if options.DisableOptimisticCache { + return nil, ErrNotCached + } + c.backgroundRefreshDNS(transport, question, c.prepareExchangeMessage(message.Copy(), options), options, responseChecker) + logOptimisticResponse(c.logger, ctx, response) + } + if response.Rcode != dns.RcodeSuccess { + return nil, RcodeError(response.Rcode) + } + return MessageToAddresses(response), nil +} + +func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { + if c.dnsCache != nil { + return c.loadPersistentResponse(question, transport) + } + if c.cache == nil { + return nil, 0, false + } + key := dnsCacheKey{Question: question, transportTag: transport.Tag()} + if c.disableExpire { + response, loaded := c.cache.Get(key) + if !loaded { + return nil, 0, false + } + return response.Copy(), 0, false + } + response, expireAt, loaded := c.cache.GetWithLifetimeNoExpire(key) + if !loaded { + return nil, 0, false + } + timeNow := time.Now() + if timeNow.After(expireAt) { + if c.optimisticTimeout > 0 && timeNow.Before(expireAt.Add(c.optimisticTimeout)) { + response = response.Copy() + normalizeTTL(response, 1) + return response, 0, true + } + c.cache.Remove(key) + return nil, 0, false + } + nowTTL := int(expireAt.Sub(timeNow).Seconds()) + if nowTTL < 0 { + nowTTL = 0 + } + response = response.Copy() + normalizeTTL(response, uint32(nowTTL)) + return response, nowTTL, false +} + +func (c *Client) loadPersistentResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { + rawMessage, expireAt, loaded := c.dnsCache.LoadDNSCache(transport.Tag(), question.Name, question.Qtype) + if !loaded { + return nil, 0, false + } + response := new(dns.Msg) + err := response.Unpack(rawMessage) + if err != nil { + return nil, 0, false + } + if c.disableExpire { + return response, 0, false + } + timeNow := time.Now() + if timeNow.After(expireAt) { + if c.optimisticTimeout > 0 && timeNow.Before(expireAt.Add(c.optimisticTimeout)) { + normalizeTTL(response, 1) + return response, 0, true + } + return nil, 0, false + } + nowTTL := int(expireAt.Sub(timeNow).Seconds()) + if nowTTL < 0 { + nowTTL = 0 + } + normalizeTTL(response, uint32(nowTTL)) + return response, nowTTL, false +} + +func applyResponseOptions(question dns.Question, response *dns.Msg, options adapter.DNSQueryOptions) uint32 { + if question.Qtype == dns.TypeHTTPS && (options.Strategy == C.DomainStrategyIPv4Only || options.Strategy == C.DomainStrategyIPv6Only) { + for _, rr := range response.Answer { + https, isHTTPS := rr.(*dns.HTTPS) + if !isHTTPS { + continue + } + content := https.SVCB + content.Value = common.Filter(content.Value, func(it dns.SVCBKeyValue) bool { + if options.Strategy == C.DomainStrategyIPv4Only { + return it.Key() != dns.SVCB_IPV6HINT + } + return it.Key() != dns.SVCB_IPV4HINT + }) + https.SVCB = content + } + } + timeToLive := computeTimeToLive(response) + if options.RewriteTTL != nil { + timeToLive = *options.RewriteTTL + } + normalizeTTL(response, timeToLive) + return timeToLive +} + +func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) { + key := dnsCacheKey{Question: question, transportTag: transport.Tag()} + _, loaded := c.backgroundRefresh.LoadOrStore(key, struct{}{}) + if loaded { + return + } + go func() { + defer c.backgroundRefresh.Delete(key) + ctx := contextWithTransportTag(c.ctx, transport.Tag()) + response, err := c.exchangeToTransport(ctx, transport, message) + if err != nil { + if c.logger != nil { + c.logger.Debug("optimistic refresh failed for ", FqdnToDomain(question.Name), ": ", err) + } + return + } + if responseChecker != nil { + var rejected bool + if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { + rejected = true + } else { + rejected = !responseChecker(response) + } + if rejected { + if c.rdrc != nil { + c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger) + } + return + } + } else if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { + return + } + timeToLive := applyResponseOptions(question, response, options) + c.storeCache(transport, question, response, timeToLive) + }() +} + +func (c *Client) prepareExchangeMessage(message *dns.Msg, options adapter.DNSQueryOptions) *dns.Msg { + clientSubnet := options.ClientSubnet + if !clientSubnet.IsValid() { + clientSubnet = c.clientSubnet + } + if clientSubnet.IsValid() { + message = SetClientSubnet(message, clientSubnet) + } + return message +} + +func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg) (*dns.Msg, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + response, err := transport.Exchange(ctx, message) + if err == nil { + return response, nil + } + var rcodeError RcodeError + if errors.As(err, &rcodeError) { + return FixedResponseStatus(message, int(rcodeError)), nil + } + return nil, err +} + +func MessageToAddresses(response *dns.Msg) []netip.Addr { + return adapter.DNSResponseAddresses(response) +} + +func wrapError(err error) error { + switch dnsErr := err.(type) { + case *net.DNSError: + if dnsErr.IsNotFound { + return RcodeNameError + } + case *net.AddrError: + return RcodeNameError + } + return err +} + +type transportKey struct{} + +func contextWithTransportTag(ctx context.Context, transportTag string) context.Context { + return context.WithValue(ctx, transportKey{}, transportTag) +} + +func transportTagFromContext(ctx context.Context) (string, bool) { + value, loaded := ctx.Value(transportKey{}).(string) + return value, loaded +} + +func FixedResponseStatus(message *dns.Msg, rcode int) *dns.Msg { + return &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: message.Id, + Response: true, + Authoritative: true, + RecursionDesired: true, + RecursionAvailable: true, + Rcode: rcode, + }, + Question: message.Question, + } +} + +func FixedResponse(id uint16, question dns.Question, addresses []netip.Addr, timeToLive uint32) *dns.Msg { + response := dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: id, + Response: true, + Authoritative: true, + RecursionDesired: true, + RecursionAvailable: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{question}, + } + for _, address := range addresses { + if address.Is4() && question.Qtype == dns.TypeA { + response.Answer = append(response.Answer, &dns.A{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: timeToLive, + }, + A: address.AsSlice(), + }) + } else if address.Is6() && question.Qtype == dns.TypeAAAA { + response.Answer = append(response.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: timeToLive, + }, + AAAA: address.AsSlice(), + }) + } + } + return &response +} + +func FixedResponseCNAME(id uint16, question dns.Question, record string, timeToLive uint32) *dns.Msg { + response := dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: id, + Response: true, + Authoritative: true, + RecursionDesired: true, + RecursionAvailable: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{question}, + Answer: []dns.RR{ + &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: timeToLive, + }, + Target: record, + }, + }, + } + return &response +} + +func FixedResponseTXT(id uint16, question dns.Question, records []string, timeToLive uint32) *dns.Msg { + response := dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: id, + Response: true, + Authoritative: true, + RecursionDesired: true, + RecursionAvailable: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{question}, + Answer: []dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: timeToLive, + }, + Txt: records, + }, + }, + } + return &response +} + +func FixedResponseMX(id uint16, question dns.Question, records []*net.MX, timeToLive uint32) *dns.Msg { + response := dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: id, + Response: true, + Authoritative: true, + RecursionDesired: true, + RecursionAvailable: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{question}, + } + for _, record := range records { + response.Answer = append(response.Answer, &dns.MX{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: timeToLive, + }, + Preference: record.Pref, + Mx: record.Host, + }) + } + return &response +} diff --git a/dns/client_log.go b/dns/client_log.go new file mode 100644 index 00000000..129e273c --- /dev/null +++ b/dns/client_log.go @@ -0,0 +1,82 @@ +package dns + +import ( + "context" + "strings" + + "github.com/sagernet/sing/common/logger" + + "github.com/miekg/dns" +) + +func logCachedResponse(logger logger.ContextLogger, ctx context.Context, response *dns.Msg, ttl int) { + if logger == nil || len(response.Question) == 0 { + return + } + domain := FqdnToDomain(response.Question[0].Name) + logger.DebugContext(ctx, "cached ", domain, " ", dns.RcodeToString[response.Rcode], " ", ttl) + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + logger.InfoContext(ctx, "cached ", dns.Type(record.Header().Rrtype).String(), " ", FormatQuestion(record.String())) + } + } +} + +func logOptimisticResponse(logger logger.ContextLogger, ctx context.Context, response *dns.Msg) { + if logger == nil || len(response.Question) == 0 { + return + } + domain := FqdnToDomain(response.Question[0].Name) + logger.DebugContext(ctx, "optimistic ", domain, " ", dns.RcodeToString[response.Rcode]) + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + logger.InfoContext(ctx, "optimistic ", dns.Type(record.Header().Rrtype).String(), " ", FormatQuestion(record.String())) + } + } +} + +func logExchangedResponse(logger logger.ContextLogger, ctx context.Context, response *dns.Msg, ttl uint32) { + if logger == nil || len(response.Question) == 0 { + return + } + domain := FqdnToDomain(response.Question[0].Name) + logger.DebugContext(ctx, "exchanged ", domain, " ", dns.RcodeToString[response.Rcode], " ", ttl) + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + logger.InfoContext(ctx, "exchanged ", dns.Type(record.Header().Rrtype).String(), " ", FormatQuestion(record.String())) + } + } +} + +func logRejectedResponse(logger logger.ContextLogger, ctx context.Context, response *dns.Msg) { + if logger == nil || len(response.Question) == 0 { + return + } + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + logger.InfoContext(ctx, "rejected ", dns.Type(record.Header().Rrtype).String(), " ", FormatQuestion(record.String())) + } + } +} + +func FqdnToDomain(fqdn string) string { + if dns.IsFqdn(fqdn) { + return fqdn[:len(fqdn)-1] + } + return fqdn +} + +func FormatQuestion(string string) string { + for strings.HasPrefix(string, ";") { + string = string[1:] + } + string = strings.ReplaceAll(string, "\t", " ") + string = strings.ReplaceAll(string, "\n", " ") + string = strings.ReplaceAll(string, ";; ", " ") + string = strings.ReplaceAll(string, "; ", " ") + + for strings.Contains(string, " ") { + string = strings.ReplaceAll(string, " ", " ") + } + return strings.TrimSpace(string) +} diff --git a/dns/client_truncate.go b/dns/client_truncate.go new file mode 100644 index 00000000..19165f99 --- /dev/null +++ b/dns/client_truncate.go @@ -0,0 +1,30 @@ +package dns + +import ( + "github.com/sagernet/sing/common/buf" + + "github.com/miekg/dns" +) + +func TruncateDNSMessage(request *dns.Msg, response *dns.Msg, headroom int) (*buf.Buffer, error) { + maxLen := 512 + if edns0Option := request.IsEdns0(); edns0Option != nil { + if udpSize := int(edns0Option.UDPSize()); udpSize > 512 { + maxLen = udpSize + } + } + responseLen := response.Len() + if responseLen > maxLen { + response = response.Copy() + response.Truncate(maxLen) + } + buffer := buf.NewSize(headroom*2 + 1 + responseLen) + buffer.Resize(headroom, 0) + rawMessage, err := response.PackBuffer(buffer.FreeBytes()) + if err != nil { + buffer.Release() + return nil, err + } + buffer.Truncate(len(rawMessage)) + return buffer, nil +} diff --git a/dns/extension_edns0_subnet.go b/dns/extension_edns0_subnet.go new file mode 100644 index 00000000..e804fb6c --- /dev/null +++ b/dns/extension_edns0_subnet.go @@ -0,0 +1,57 @@ +package dns + +import ( + "net/netip" + + "github.com/miekg/dns" +) + +func SetClientSubnet(message *dns.Msg, clientSubnet netip.Prefix) *dns.Msg { + return setClientSubnet(message, clientSubnet, true) +} + +func setClientSubnet(message *dns.Msg, clientSubnet netip.Prefix, clone bool) *dns.Msg { + var ( + optRecord *dns.OPT + subnetOption *dns.EDNS0_SUBNET + ) +findExists: + for _, record := range message.Extra { + var isOPTRecord bool + if optRecord, isOPTRecord = record.(*dns.OPT); isOPTRecord { + for _, option := range optRecord.Option { + var isEDNS0Subnet bool + subnetOption, isEDNS0Subnet = option.(*dns.EDNS0_SUBNET) + if isEDNS0Subnet { + break findExists + } + } + } + } + if optRecord == nil { + exMessage := *message + message = &exMessage + optRecord = &dns.OPT{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeOPT, + }, + } + message.Extra = append(message.Extra, optRecord) + } else if clone { + return setClientSubnet(message.Copy(), clientSubnet, false) + } + if subnetOption == nil { + subnetOption = new(dns.EDNS0_SUBNET) + subnetOption.Code = dns.EDNS0SUBNET + optRecord.Option = append(optRecord.Option, subnetOption) + } + if clientSubnet.Addr().Is4() { + subnetOption.Family = 1 + } else { + subnetOption.Family = 2 + } + subnetOption.SourceNetmask = uint8(clientSubnet.Bits()) + subnetOption.Address = clientSubnet.Addr().AsSlice() + return message +} diff --git a/dns/rcode.go b/dns/rcode.go new file mode 100644 index 00000000..417d41fa --- /dev/null +++ b/dns/rcode.go @@ -0,0 +1,19 @@ +package dns + +import ( + mDNS "github.com/miekg/dns" +) + +const ( + RcodeSuccess RcodeError = mDNS.RcodeSuccess + RcodeServerFailure RcodeError = mDNS.RcodeServerFailure + RcodeFormatError RcodeError = mDNS.RcodeFormatError + RcodeNameError RcodeError = mDNS.RcodeNameError + RcodeRefused RcodeError = mDNS.RcodeRefused +) + +type RcodeError int + +func (e RcodeError) Error() string { + return mDNS.RcodeToString[int(e)] +} diff --git a/dns/repro_test.go b/dns/repro_test.go new file mode 100644 index 00000000..113f7c49 --- /dev/null +++ b/dns/repro_test.go @@ -0,0 +1,111 @@ +package dns + +import ( + "context" + "net/netip" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestReproLookupWithRulesUsesRequestStrategy(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + var qTypes []uint16 + router := newTestRouter(t, nil, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + qTypes = append(qTypes, message.Question[0].Qtype) + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + }, + }) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + Strategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []uint16{mDNS.TypeA}, qTypes) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) +} + +func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} diff --git a/dns/router.go b/dns/router.go new file mode 100644 index 00000000..b9fc8f97 --- /dev/null +++ b/dns/router.go @@ -0,0 +1,1149 @@ +package dns + +import ( + "context" + "errors" + "net/netip" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + R "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +var ( + _ adapter.DNSRouter = (*Router)(nil) + _ adapter.DNSRuleSetUpdateValidator = (*Router)(nil) +) + +type Router struct { + ctx context.Context + logger logger.ContextLogger + transport adapter.DNSTransportManager + outbound adapter.OutboundManager + client adapter.DNSClient + rawRules []option.DNSRule + rules []adapter.DNSRule + defaultDomainStrategy C.DomainStrategy + dnsReverseMapping freelru.Cache[netip.Addr, string] + platformInterface adapter.PlatformInterface + legacyDNSMode bool + rulesAccess sync.RWMutex + started bool + closing bool +} + +func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) (*Router, error) { + router := &Router{ + ctx: ctx, + logger: logFactory.NewLogger("dns"), + transport: service.FromContext[adapter.DNSTransportManager](ctx), + outbound: service.FromContext[adapter.OutboundManager](ctx), + rawRules: make([]option.DNSRule, 0, len(options.Rules)), + rules: make([]adapter.DNSRule, 0, len(options.Rules)), + defaultDomainStrategy: C.DomainStrategy(options.Strategy), + } + if options.DNSClientOptions.IndependentCache { + deprecated.Report(ctx, deprecated.OptionIndependentDNSCache) + } + var optimisticTimeout time.Duration + optimisticOptions := common.PtrValueOrDefault(options.DNSClientOptions.Optimistic) + if optimisticOptions.Enabled { + if options.DNSClientOptions.DisableCache { + return nil, E.New("`optimistic` is conflict with `disable_cache`") + } + if options.DNSClientOptions.DisableExpire { + return nil, E.New("`optimistic` is conflict with `disable_expire`") + } + optimisticTimeout = time.Duration(optimisticOptions.Timeout) + if optimisticTimeout == 0 { + optimisticTimeout = 3 * 24 * time.Hour + } + } + router.client = NewClient(ClientOptions{ + Context: ctx, + DisableCache: options.DNSClientOptions.DisableCache, + DisableExpire: options.DNSClientOptions.DisableExpire, + OptimisticTimeout: optimisticTimeout, + CacheCapacity: options.DNSClientOptions.CacheCapacity, + ClientSubnet: options.DNSClientOptions.ClientSubnet.Build(netip.Prefix{}), + RDRC: func() adapter.RDRCStore { + cacheFile := service.FromContext[adapter.CacheFile](ctx) + if cacheFile == nil { + return nil + } + if !cacheFile.StoreRDRC() { + return nil + } + return cacheFile + }, + DNSCache: func() adapter.DNSCacheStore { + cacheFile := service.FromContext[adapter.CacheFile](ctx) + if cacheFile == nil { + return nil + } + if !cacheFile.StoreDNS() { + return nil + } + cacheFile.SetDisableExpire(options.DNSClientOptions.DisableExpire) + cacheFile.SetOptimisticTimeout(optimisticTimeout) + return cacheFile + }, + Logger: router.logger, + }) + if options.ReverseMapping { + router.dnsReverseMapping = common.Must1(freelru.NewSharded[netip.Addr, string](1024, maphash.NewHasher[netip.Addr]().Hash32)) + } + return router, nil +} + +func (r *Router) Initialize(rules []option.DNSRule) error { + r.rawRules = append(r.rawRules[:0], rules...) + newRules, _, _, err := r.buildRules(false) + if err != nil { + return err + } + closeRules(newRules) + return nil +} + +func (r *Router) Start(stage adapter.StartStage) error { + monitor := taskmonitor.New(r.logger, C.StartTimeout) + switch stage { + case adapter.StartStateStart: + monitor.Start("initialize DNS client") + r.client.Start() + monitor.Finish() + + monitor.Start("initialize DNS rules") + newRules, legacyDNSMode, modeFlags, err := r.buildRules(true) + monitor.Finish() + if err != nil { + return err + } + r.rulesAccess.Lock() + if r.closing { + r.rulesAccess.Unlock() + closeRules(newRules) + return nil + } + r.rules = newRules + r.legacyDNSMode = legacyDNSMode + r.started = true + r.rulesAccess.Unlock() + if legacyDNSMode && common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) + } + if legacyDNSMode && modeFlags.neededFromStrategy { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSRuleStrategy) + } + } + return nil +} + +func (r *Router) Close() error { + r.rulesAccess.Lock() + if r.closing { + r.rulesAccess.Unlock() + return nil + } + r.closing = true + runtimeRules := r.rules + r.rules = nil + r.rulesAccess.Unlock() + closeRules(runtimeRules) + return nil +} + +func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleModeFlags, error) { + for i, ruleOptions := range r.rawRules { + err := R.ValidateNoNestedDNSRuleActions(ruleOptions) + if err != nil { + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "parse dns rule[", i, "]") + } + } + router := service.FromContext[adapter.Router](r.ctx) + legacyDNSMode, modeFlags, err := resolveLegacyDNSMode(router, r.rawRules, nil) + if err != nil { + return nil, false, dnsRuleModeFlags{}, err + } + if !legacyDNSMode { + err = validateLegacyDNSModeDisabledRules(r.rawRules) + if err != nil { + return nil, false, dnsRuleModeFlags{}, err + } + } + err = validateEvaluateFakeIPRules(r.rawRules, r.transport) + if err != nil { + return nil, false, dnsRuleModeFlags{}, err + } + newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) + for i, ruleOptions := range r.rawRules { + var dnsRule adapter.DNSRule + dnsRule, err = R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyDNSMode) + if err != nil { + closeRules(newRules) + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "parse dns rule[", i, "]") + } + newRules = append(newRules, dnsRule) + } + if startRules { + for i, rule := range newRules { + err = rule.Start() + if err != nil { + closeRules(newRules) + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "initialize DNS rule[", i, "]") + } + } + } + return newRules, legacyDNSMode, modeFlags, nil +} + +func closeRules(rules []adapter.DNSRule) { + for _, rule := range rules { + _ = rule.Close() + } +} + +func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.RuleSetMetadata) error { + if len(r.rawRules) == 0 { + return nil + } + router := service.FromContext[adapter.Router](r.ctx) + if router == nil { + return E.New("router service not found") + } + overrides := map[string]adapter.RuleSetMetadata{ + tag: metadata, + } + r.rulesAccess.RLock() + started := r.started + legacyDNSMode := r.legacyDNSMode + closing := r.closing + r.rulesAccess.RUnlock() + if closing { + return nil + } + if !started { + candidateLegacyDNSMode, _, err := resolveLegacyDNSMode(router, r.rawRules, overrides) + if err != nil { + return err + } + if !candidateLegacyDNSMode { + return validateLegacyDNSModeDisabledRules(r.rawRules) + } + return nil + } + candidateLegacyDNSMode, flags, err := resolveLegacyDNSMode(router, r.rawRules, overrides) + if err != nil { + return err + } + if legacyDNSMode { + if !candidateLegacyDNSMode && flags.disabled { + err := validateLegacyDNSModeDisabledRules(r.rawRules) + if err != nil { + return err + } + return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + } + return nil + } + if candidateLegacyDNSMode { + return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + } + return nil +} + +func (r *Router) matchDNS(ctx context.Context, rules []adapter.DNSRule, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { + metadata := adapter.ContextFrom(ctx) + if metadata == nil { + panic("no context") + } + var currentRuleIndex int + if ruleIndex != -1 { + currentRuleIndex = ruleIndex + 1 + } + for ; currentRuleIndex < len(rules); currentRuleIndex++ { + currentRule := rules[currentRuleIndex] + if currentRule.WithAddressLimit() && !isAddressQuery { + continue + } + metadata.ResetRuleCache() + metadata.DestinationAddressMatchFromResponse = false + if currentRule.LegacyPreMatch(metadata) { + if ruleDescription := currentRule.String(); ruleDescription != "" { + r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + } else { + r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] => ", currentRule.Action()) + } + switch action := currentRule.Action().(type) { + case *R.RuleActionDNSRoute: + transport, loaded := r.transport.Transport(action.Server) + if !loaded { + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + continue + } + isFakeIP := transport.Type() == C.DNSTypeFakeIP + if isFakeIP && !allowFakeIP { + continue + } + if action.Strategy != C.DomainStrategyAsIS { + options.Strategy = action.Strategy + } + if isFakeIP || action.DisableCache { + options.DisableCache = true + } + if action.RewriteTTL != nil { + options.RewriteTTL = action.RewriteTTL + } + if action.ClientSubnet.IsValid() { + options.ClientSubnet = action.ClientSubnet + } + return transport, currentRule, currentRuleIndex + case *R.RuleActionDNSRouteOptions: + if action.Strategy != C.DomainStrategyAsIS { + options.Strategy = action.Strategy + } + if action.DisableCache { + options.DisableCache = true + } + if action.RewriteTTL != nil { + options.RewriteTTL = action.RewriteTTL + } + if action.ClientSubnet.IsValid() { + options.ClientSubnet = action.ClientSubnet + } + case *R.RuleActionReject: + return nil, currentRule, currentRuleIndex + case *R.RuleActionPredefined: + return nil, currentRule, currentRuleIndex + } + } + } + transport := r.transport.Default() + return transport, nil, -1 +} + +func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) { + // Strategy is intentionally skipped here. A non-default DNS rule action strategy + // forces legacy mode via resolveLegacyDNSMode, so this path is only reachable + // when strategy remains at its default value. + if routeOptions.DisableCache { + options.DisableCache = true + } + if routeOptions.DisableOptimisticCache { + options.DisableOptimisticCache = true + } + if routeOptions.RewriteTTL != nil { + options.RewriteTTL = routeOptions.RewriteTTL + } + if routeOptions.ClientSubnet.IsValid() { + options.ClientSubnet = routeOptions.ClientSubnet + } +} + +type dnsRouteStatus uint8 + +const ( + dnsRouteStatusMissing dnsRouteStatus = iota + dnsRouteStatusSkipped + dnsRouteStatusResolved +) + +func (r *Router) resolveDNSRoute(server string, routeOptions R.RuleActionDNSRouteOptions, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, dnsRouteStatus) { + transport, loaded := r.transport.Transport(server) + if !loaded { + return nil, dnsRouteStatusMissing + } + isFakeIP := transport.Type() == C.DNSTypeFakeIP + if isFakeIP && !allowFakeIP { + return transport, dnsRouteStatusSkipped + } + r.applyDNSRouteOptions(options, routeOptions) + if isFakeIP { + options.DisableCache = true + } + return transport, dnsRouteStatusResolved +} + +func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule adapter.DNSRule) { + if ruleDescription := currentRule.String(); ruleDescription != "" { + r.logger.DebugContext(ctx, "match[", ruleIndex, "] ", currentRule, " => ", currentRule.Action()) + } else { + r.logger.DebugContext(ctx, "match[", ruleIndex, "] => ", currentRule.Action()) + } +} + +type exchangeWithRulesResult struct { + response *mDNS.Msg + transport adapter.DNSTransport + rejectAction *R.RuleActionReject + err error +} + +const dnsRespondMissingResponseMessage = "respond action requires an evaluated response from a preceding evaluate action" + +func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { + metadata := adapter.ContextFrom(ctx) + if metadata == nil { + panic("no context") + } + effectiveOptions := options + var evaluatedResponse *mDNS.Msg + var evaluatedTransport adapter.DNSTransport + for currentRuleIndex, currentRule := range rules { + metadata.ResetRuleCache() + metadata.DNSResponse = evaluatedResponse + metadata.DestinationAddressMatchFromResponse = false + if !currentRule.Match(metadata) { + continue + } + r.logRuleMatch(ctx, currentRuleIndex, currentRule) + switch action := currentRule.Action().(type) { + case *R.RuleActionDNSRouteOptions: + r.applyDNSRouteOptions(&effectiveOptions, *action) + case *R.RuleActionEvaluate: + queryOptions := effectiveOptions + transport, loaded := r.transport.Transport(action.Server) + if !loaded { + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + evaluatedResponse = nil + evaluatedTransport = nil + continue + } + r.applyDNSRouteOptions(&queryOptions, action.RuleActionDNSRouteOptions) + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + if err != nil { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + evaluatedResponse = nil + evaluatedTransport = nil + continue + } + evaluatedResponse = response + evaluatedTransport = transport + case *R.RuleActionRespond: + if evaluatedResponse == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + } + } + return exchangeWithRulesResult{ + response: evaluatedResponse, + transport: evaluatedTransport, + } + case *R.RuleActionDNSRoute: + queryOptions := effectiveOptions + transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) + switch status { + case dnsRouteStatusMissing: + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + continue + case dnsRouteStatusSkipped: + continue + } + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + return exchangeWithRulesResult{ + response: response, + transport: transport, + err: err, + } + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return exchangeWithRulesResult{ + response: &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + }, + rejectAction: action, + } + case C.RuleActionRejectMethodDrop: + return exchangeWithRulesResult{ + rejectAction: action, + err: tun.ErrDrop, + } + } + case *R.RuleActionPredefined: + return exchangeWithRulesResult{ + response: action.Response(message), + } + } + } + transport := r.transport.Default() + exchangeOptions := effectiveOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + return exchangeWithRulesResult{ + response: response, + transport: transport, + err: err, + } +} + +func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { + if options.LookupStrategy != C.DomainStrategyAsIS { + return options.LookupStrategy + } + if options.Strategy != C.DomainStrategyAsIS { + return options.Strategy + } + return r.defaultDomainStrategy +} + +func withLookupQueryMetadata(ctx context.Context, qType uint16) context.Context { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.QueryType = qType + metadata.IPVersion = 0 + switch qType { + case mDNS.TypeA: + metadata.IPVersion = 4 + case mDNS.TypeAAAA: + metadata.IPVersion = 6 + } + return ctx +} + +func filterAddressesByQueryType(addresses []netip.Addr, qType uint16) []netip.Addr { + switch qType { + case mDNS.TypeA: + return common.Filter(addresses, func(address netip.Addr) bool { + return address.Is4() + }) + case mDNS.TypeAAAA: + return common.Filter(addresses, func(address netip.Addr) bool { + return address.Is6() + }) + default: + return addresses + } +} + +func (r *Router) lookupWithRules(ctx context.Context, rules []adapter.DNSRule, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + strategy := r.resolveLookupStrategy(options) + lookupOptions := options + if strategy != C.DomainStrategyAsIS { + lookupOptions.Strategy = strategy + } + if strategy == C.DomainStrategyIPv4Only { + return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) + } + if strategy == C.DomainStrategyIPv6Only { + return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) + } + var ( + response4 []netip.Addr + response6 []netip.Addr + ) + var group task.Group + group.Append("exchange4", func(ctx context.Context) error { + result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) + response4 = result + return err + }) + group.Append("exchange6", func(ctx context.Context) error { + result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) + response6 = result + return err + }) + err := group.Run(ctx) + if len(response4) == 0 && len(response6) == 0 { + return nil, err + } + return sortAddresses(response4, response6, strategy), nil +} + +func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRule, domain string, qType uint16, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{{ + Name: mDNS.Fqdn(domain), + Qtype: qType, + Qclass: mDNS.ClassINET, + }}, + } + exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false) + if exchangeResult.rejectAction != nil { + return nil, exchangeResult.rejectAction.Error(ctx) + } + if exchangeResult.err != nil { + return nil, exchangeResult.err + } + if exchangeResult.response.Rcode != mDNS.RcodeSuccess { + return nil, RcodeError(exchangeResult.response.Rcode) + } + return filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType), nil +} + +func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { + if len(message.Question) != 1 { + r.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) + responseMessage := mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Response: true, + Rcode: mDNS.RcodeFormatError, + }, + Question: message.Question, + } + return &responseMessage, nil + } + r.rulesAccess.RLock() + if r.closing { + r.rulesAccess.RUnlock() + return nil, E.New("dns router closed") + } + rules := r.rules + legacyDNSMode := r.legacyDNSMode + r.rulesAccess.RUnlock() + r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) + var ( + response *mDNS.Msg + transport adapter.DNSTransport + err error + ) + var metadata *adapter.InboundContext + ctx, metadata = adapter.ExtendContext(ctx) + metadata.Destination = M.Socksaddr{} + metadata.QueryType = message.Question[0].Qtype + metadata.DNSResponse = nil + metadata.DestinationAddressMatchFromResponse = false + switch metadata.QueryType { + case mDNS.TypeA: + metadata.IPVersion = 4 + case mDNS.TypeAAAA: + metadata.IPVersion = 6 + } + metadata.Domain = FqdnToDomain(message.Question[0].Name) + if options.Transport != nil { + transport = options.Transport + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = r.defaultDomainStrategy + } + response, err = r.client.Exchange(ctx, transport, message, options, nil) + } else if !legacyDNSMode { + exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) + response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err + } else { + var ( + rule adapter.DNSRule + ruleIndex int + ) + ruleIndex = -1 + for { + dnsCtx := adapter.OverrideContext(ctx) + dnsOptions := options + transport, rule, ruleIndex = r.matchDNS(ctx, rules, true, ruleIndex, isAddressQuery(message), &dnsOptions) + if rule != nil { + switch action := rule.Action().(type) { + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil + case C.RuleActionRejectMethodDrop: + return nil, tun.ErrDrop + } + case *R.RuleActionPredefined: + err = nil + response = action.Response(message) + goto done + } + } + responseCheck := addressLimitResponseCheck(rule, metadata) + if dnsOptions.Strategy == C.DomainStrategyAsIS { + dnsOptions.Strategy = r.defaultDomainStrategy + } + response, err = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) + var rejected bool + if err != nil { + if errors.Is(err, ErrResponseRejectedCached) { + rejected = true + r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String())), " (cached)") + } else if errors.Is(err, ErrResponseRejected) { + rejected = true + r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String()))) + } else if len(message.Question) > 0 { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + } else { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ")) + } + } + if responseCheck != nil && rejected { + continue + } + break + } + } +done: + if err != nil { + return nil, err + } + if r.dnsReverseMapping != nil && len(message.Question) > 0 && response != nil && len(response.Answer) > 0 { + if transport == nil || transport.Type() != C.DNSTypeFakeIP { + for _, answer := range response.Answer { + switch record := answer.(type) { + case *mDNS.A: + r.dnsReverseMapping.AddWithLifetime(M.AddrFromIP(record.A), FqdnToDomain(record.Hdr.Name), time.Duration(record.Hdr.Ttl)*time.Second) + case *mDNS.AAAA: + r.dnsReverseMapping.AddWithLifetime(M.AddrFromIP(record.AAAA), FqdnToDomain(record.Hdr.Name), time.Duration(record.Hdr.Ttl)*time.Second) + } + } + } + } + return response, nil +} + +func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + r.rulesAccess.RLock() + if r.closing { + r.rulesAccess.RUnlock() + return nil, E.New("dns router closed") + } + rules := r.rules + legacyDNSMode := r.legacyDNSMode + r.rulesAccess.RUnlock() + var ( + responseAddrs []netip.Addr + err error + ) + printResult := func() { + if err == nil && len(responseAddrs) == 0 { + err = E.New("empty result") + } + if err != nil { + if errors.Is(err, ErrResponseRejectedCached) { + r.logger.DebugContext(ctx, "response rejected for ", domain, " (cached)") + } else if errors.Is(err, ErrResponseRejected) { + r.logger.DebugContext(ctx, "response rejected for ", domain) + } else if R.IsRejected(err) { + r.logger.DebugContext(ctx, "lookup rejected for ", domain) + } else { + r.logger.ErrorContext(ctx, E.Cause(err, "lookup failed for ", domain)) + } + } + if err != nil { + err = E.Cause(err, "lookup ", domain) + } + } + r.logger.DebugContext(ctx, "lookup domain ", domain) + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Destination = M.Socksaddr{} + metadata.Domain = FqdnToDomain(domain) + metadata.DNSResponse = nil + metadata.DestinationAddressMatchFromResponse = false + if options.Transport != nil { + transport := options.Transport + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = r.defaultDomainStrategy + } + responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) + } else if !legacyDNSMode { + responseAddrs, err = r.lookupWithRules(ctx, rules, domain, options) + } else { + var ( + transport adapter.DNSTransport + rule adapter.DNSRule + ruleIndex int + ) + ruleIndex = -1 + for { + dnsCtx := adapter.OverrideContext(ctx) + dnsOptions := options + transport, rule, ruleIndex = r.matchDNS(ctx, rules, false, ruleIndex, true, &dnsOptions) + if rule != nil { + switch action := rule.Action().(type) { + case *R.RuleActionReject: + return nil, &R.RejectedError{Cause: action.Error(ctx)} + case *R.RuleActionPredefined: + responseAddrs = nil + if action.Rcode != mDNS.RcodeSuccess { + err = RcodeError(action.Rcode) + } else { + err = nil + for _, answer := range action.Answer { + switch record := answer.(type) { + case *mDNS.A: + responseAddrs = append(responseAddrs, M.AddrFromIP(record.A)) + case *mDNS.AAAA: + responseAddrs = append(responseAddrs, M.AddrFromIP(record.AAAA)) + } + } + } + goto response + } + } + responseCheck := addressLimitResponseCheck(rule, metadata) + if dnsOptions.Strategy == C.DomainStrategyAsIS { + dnsOptions.Strategy = r.defaultDomainStrategy + } + responseAddrs, err = r.client.Lookup(dnsCtx, transport, domain, dnsOptions, responseCheck) + if responseCheck == nil || err == nil { + break + } + printResult() + } + } +response: + printResult() + if len(responseAddrs) > 0 { + r.logger.InfoContext(ctx, "lookup succeed for ", domain, ": ", strings.Join(F.MapToString(responseAddrs), " ")) + } + return responseAddrs, err +} + +func isAddressQuery(message *mDNS.Msg) bool { + for _, question := range message.Question { + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA || question.Qtype == mDNS.TypeHTTPS { + return true + } + } + return false +} + +func addressLimitResponseCheck(rule adapter.DNSRule, metadata *adapter.InboundContext) func(response *mDNS.Msg) bool { + if rule == nil || !rule.WithAddressLimit() { + return nil + } + responseMetadata := *metadata + return func(response *mDNS.Msg) bool { + checkMetadata := responseMetadata + return rule.MatchAddressLimit(&checkMetadata, response) + } +} + +func (r *Router) ClearCache() { + r.client.ClearCache() + if r.platformInterface != nil { + r.platformInterface.ClearDNSCache() + } +} + +func (r *Router) LookupReverseMapping(ip netip.Addr) (string, bool) { + if r.dnsReverseMapping == nil { + return "", false + } + domain, loaded := r.dnsReverseMapping.Get(ip) + return domain, loaded +} + +func (r *Router) ResetNetwork() { + r.ClearCache() + for _, transport := range r.transport.Transports() { + transport.Reset() + } +} + +func defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule option.DefaultDNSRule) bool { + if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck + return true + } + return !rule.MatchResponse && (rule.IPAcceptAny || len(rule.IPCIDR) > 0 || rule.IPIsPrivate) +} + +func hasResponseMatchFields(rule option.DefaultDNSRule) bool { + return rule.ResponseRcode != nil || + len(rule.ResponseAnswer) > 0 || + len(rule.ResponseNs) > 0 || + len(rule.ResponseExtra) > 0 +} + +func defaultRuleDisablesLegacyDNSMode(rule option.DefaultDNSRule) bool { + return rule.MatchResponse || + hasResponseMatchFields(rule) || + rule.Action == C.RuleActionTypeEvaluate || + rule.Action == C.RuleActionTypeRespond || + rule.IPVersion > 0 || + len(rule.QueryType) > 0 +} + +type dnsRuleModeFlags struct { + disabled bool + needed bool + neededFromStrategy bool +} + +func (f *dnsRuleModeFlags) merge(other dnsRuleModeFlags) { + f.disabled = f.disabled || other.disabled + f.needed = f.needed || other.needed + f.neededFromStrategy = f.neededFromStrategy || other.neededFromStrategy +} + +func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (bool, dnsRuleModeFlags, error) { + flags, err := dnsRuleModeRequirements(router, rules, metadataOverrides) + if err != nil { + return false, flags, err + } + if flags.disabled && flags.neededFromStrategy { + return false, flags, E.New(deprecated.OptionLegacyDNSRuleStrategy.MessageWithLink()) + } + if flags.disabled { + return false, flags, nil + } + return flags.needed, flags, nil +} + +func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { + var flags dnsRuleModeFlags + for i, rule := range rules { + ruleFlags, err := dnsRuleModeRequirementsInRule(router, rule, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, E.Cause(err, "dns rule[", i, "]") + } + flags.merge(ruleFlags) + } + return flags, nil +} + +func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { + switch rule.Type { + case "", C.RuleTypeDefault: + return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions, metadataOverrides) + case C.RuleTypeLogical: + flags := dnsRuleModeFlags{ + disabled: dnsRuleActionType(rule) == C.RuleActionTypeEvaluate || + dnsRuleActionType(rule) == C.RuleActionTypeRespond || + dnsRuleActionDisablesLegacyDNSMode(rule.LogicalOptions.DNSRuleAction), + neededFromStrategy: dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction), + } + flags.needed = flags.neededFromStrategy + for i, subRule := range rule.LogicalOptions.Rules { + subFlags, err := dnsRuleModeRequirementsInRule(router, subRule, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, E.Cause(err, "sub rule[", i, "]") + } + flags.merge(subFlags) + } + return flags, nil + default: + return dnsRuleModeFlags{}, nil + } +} + +func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { + flags := dnsRuleModeFlags{ + disabled: defaultRuleDisablesLegacyDNSMode(rule) || dnsRuleActionDisablesLegacyDNSMode(rule.DNSRuleAction), + neededFromStrategy: dnsRuleActionHasStrategy(rule.DNSRuleAction), + } + flags.needed = defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule) || flags.neededFromStrategy + if len(rule.RuleSet) == 0 { + return flags, nil + } + if router == nil { + return dnsRuleModeFlags{}, E.New("router service not found") + } + for _, tag := range rule.RuleSet { + metadata, err := lookupDNSRuleSetMetadata(router, tag, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, err + } + // ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. + flags.disabled = flags.disabled || metadata.ContainsDNSQueryTypeRule + if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { + flags.needed = true + } + } + return flags, nil +} + +func lookupDNSRuleSetMetadata(router adapter.Router, tag string, metadataOverrides map[string]adapter.RuleSetMetadata) (adapter.RuleSetMetadata, error) { + if metadataOverrides != nil { + if metadata, loaded := metadataOverrides[tag]; loaded { + return metadata, nil + } + } + ruleSet, loaded := router.RuleSet(tag) + if !loaded { + return adapter.RuleSetMetadata{}, E.New("rule-set not found: ", tag) + } + return ruleSet.Metadata(), nil +} + +func referencedDNSRuleSetTags(rules []option.DNSRule) []string { + tagMap := make(map[string]bool) + var walkRule func(rule option.DNSRule) + walkRule = func(rule option.DNSRule) { + switch rule.Type { + case "", C.RuleTypeDefault: + for _, tag := range rule.DefaultOptions.RuleSet { + tagMap[tag] = true + } + case C.RuleTypeLogical: + for _, subRule := range rule.LogicalOptions.Rules { + walkRule(subRule) + } + } + } + for _, rule := range rules { + walkRule(rule) + } + tags := make([]string, 0, len(tagMap)) + for tag := range tagMap { + if tag != "" { + tags = append(tags, tag) + } + } + return tags +} + +func validateLegacyDNSModeDisabledRules(rules []option.DNSRule) error { + var seenEvaluate bool + for i, rule := range rules { + requiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(rule) + if err != nil { + return E.Cause(err, "validate dns rule[", i, "]") + } + if requiresPriorEvaluate && !seenEvaluate { + return E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action") + } + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + seenEvaluate = true + } + } + return nil +} + +func validateEvaluateFakeIPRules(rules []option.DNSRule, transportManager adapter.DNSTransportManager) error { + if transportManager == nil { + return nil + } + for i, rule := range rules { + if dnsRuleActionType(rule) != C.RuleActionTypeEvaluate { + continue + } + server := dnsRuleActionServer(rule) + if server == "" { + continue + } + transport, loaded := transportManager.Transport(server) + if !loaded || transport.Type() != C.DNSTypeFakeIP { + continue + } + return E.New("dns rule[", i, "]: evaluate action cannot use fakeip server: ", server) + } + return nil +} + +func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { + switch rule.Type { + case "", C.RuleTypeDefault: + return validateLegacyDNSModeDisabledDefaultRule(rule.DefaultOptions) + case C.RuleTypeLogical: + requiresPriorEvaluate := dnsRuleActionType(rule) == C.RuleActionTypeRespond + for i, subRule := range rule.LogicalOptions.Rules { + subRequiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(subRule) + if err != nil { + return false, E.Cause(err, "sub rule[", i, "]") + } + requiresPriorEvaluate = requiresPriorEvaluate || subRequiresPriorEvaluate + } + return requiresPriorEvaluate, nil + default: + return false, nil + } +} + +func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, error) { + hasResponseRecords := hasResponseMatchFields(rule) + if (hasResponseRecords || len(rule.IPCIDR) > 0 || rule.IPIsPrivate || rule.IPAcceptAny) && !rule.MatchResponse { + return false, E.New("Response Match Fields (ip_cidr, ip_is_private, ip_accept_any, response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") + } + // Intentionally do not reject rule_set here. A referenced rule set may mix + // destination-IP predicates with pre-response predicates such as domain items. + // When match_response is false, those destination-IP branches fail closed during + // pre-response evaluation instead of consuming DNS response state, while sibling + // non-response branches remain matchable. + if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck + return false, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + } + return rule.MatchResponse || rule.Action == C.RuleActionTypeRespond, nil +} + +func dnsRuleActionDisablesLegacyDNSMode(action option.DNSRuleAction) bool { + switch action.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + return action.RouteOptions.DisableOptimisticCache + case C.RuleActionTypeRouteOptions: + return action.RouteOptionsOptions.DisableOptimisticCache + default: + return false + } +} + +func dnsRuleActionHasStrategy(action option.DNSRuleAction) bool { + switch action.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + return C.DomainStrategy(action.RouteOptions.Strategy) != C.DomainStrategyAsIS + case C.RuleActionTypeRouteOptions: + return C.DomainStrategy(action.RouteOptionsOptions.Strategy) != C.DomainStrategyAsIS + default: + return false + } +} + +func dnsRuleActionType(rule option.DNSRule) string { + switch rule.Type { + case "", C.RuleTypeDefault: + if rule.DefaultOptions.Action == "" { + return C.RuleActionTypeRoute + } + return rule.DefaultOptions.Action + case C.RuleTypeLogical: + if rule.LogicalOptions.Action == "" { + return C.RuleActionTypeRoute + } + return rule.LogicalOptions.Action + default: + return "" + } +} + +func dnsRuleActionServer(rule option.DNSRule) string { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.RouteOptions.Server + case C.RuleTypeLogical: + return rule.LogicalOptions.RouteOptions.Server + default: + return "" + } +} diff --git a/dns/router_test.go b/dns/router_test.go new file mode 100644 index 00000000..54213b23 --- /dev/null +++ b/dns/router_test.go @@ -0,0 +1,2547 @@ +package dns + +import ( + "context" + "net" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + rulepkg "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" + "go4.org/netipx" +) + +type fakeDNSTransport struct { + tag string + transportType string +} + +func (t *fakeDNSTransport) Start(adapter.StartStage) error { return nil } +func (t *fakeDNSTransport) Close() error { return nil } +func (t *fakeDNSTransport) Type() string { return t.transportType } +func (t *fakeDNSTransport) Tag() string { return t.tag } +func (t *fakeDNSTransport) Dependencies() []string { return nil } +func (t *fakeDNSTransport) Reset() {} +func (t *fakeDNSTransport) Exchange(context.Context, *mDNS.Msg) (*mDNS.Msg, error) { + return nil, E.New("unused transport exchange") +} + +type fakeDNSTransportManager struct { + defaultTransport adapter.DNSTransport + transports map[string]adapter.DNSTransport +} + +func (m *fakeDNSTransportManager) Start(adapter.StartStage) error { return nil } +func (m *fakeDNSTransportManager) Close() error { return nil } +func (m *fakeDNSTransportManager) Transports() []adapter.DNSTransport { + transports := make([]adapter.DNSTransport, 0, len(m.transports)) + for _, transport := range m.transports { + transports = append(transports, transport) + } + return transports +} + +func (m *fakeDNSTransportManager) Transport(tag string) (adapter.DNSTransport, bool) { + transport, loaded := m.transports[tag] + return transport, loaded +} +func (m *fakeDNSTransportManager) Default() adapter.DNSTransport { return m.defaultTransport } +func (m *fakeDNSTransportManager) FakeIP() adapter.FakeIPTransport { + return nil +} +func (m *fakeDNSTransportManager) Remove(string) error { return nil } +func (m *fakeDNSTransportManager) Create(context.Context, log.ContextLogger, string, string, any) error { + return E.New("unsupported") +} + +type fakeDNSClient struct { + beforeExchange func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg) + exchange func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) + lookupWithCtx func(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) + lookup func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) +} + +type fakeDeprecatedManager struct { + features []deprecated.Note +} + +type fakeRouter struct { + access sync.RWMutex + ruleSets map[string]adapter.RuleSet +} + +func (r *fakeRouter) Start(adapter.StartStage) error { return nil } +func (r *fakeRouter) Close() error { return nil } +func (r *fakeRouter) PreMatch(metadata adapter.InboundContext, _ tun.DirectRouteContext, _ time.Duration, _ bool) (tun.DirectRouteDestination, error) { + return nil, nil +} + +func (r *fakeRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { + return nil +} + +func (r *fakeRouter) RoutePacketConnection(context.Context, N.PacketConn, adapter.InboundContext) error { + return nil +} + +func (r *fakeRouter) RouteConnectionEx(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *fakeRouter) RoutePacketConnectionEx(context.Context, N.PacketConn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *fakeRouter) RuleSet(tag string) (adapter.RuleSet, bool) { + r.access.RLock() + defer r.access.RUnlock() + ruleSet, loaded := r.ruleSets[tag] + return ruleSet, loaded +} + +func (r *fakeRouter) setRuleSet(tag string, ruleSet adapter.RuleSet) { + r.access.Lock() + defer r.access.Unlock() + if r.ruleSets == nil { + r.ruleSets = make(map[string]adapter.RuleSet) + } + r.ruleSets[tag] = ruleSet +} +func (r *fakeRouter) Rules() []adapter.Rule { return nil } +func (r *fakeRouter) NeedFindProcess() bool { return false } +func (r *fakeRouter) NeedFindNeighbor() bool { return false } +func (r *fakeRouter) NeighborResolver() adapter.NeighborResolver { return nil } +func (r *fakeRouter) AppendTracker(adapter.ConnectionTracker) {} +func (r *fakeRouter) ResetNetwork() {} + +type fakeRuleSet struct { + access sync.Mutex + metadata adapter.RuleSetMetadata + metadataRead func(adapter.RuleSetMetadata) adapter.RuleSetMetadata + match func(*adapter.InboundContext) bool + callbacks list.List[adapter.RuleSetUpdateCallback] + refs int + afterIncrementReference func() + beforeDecrementReference func() +} + +func (s *fakeRuleSet) Name() string { return "fake-rule-set" } +func (s *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } +func (s *fakeRuleSet) PostStart() error { return nil } +func (s *fakeRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.Lock() + metadata := s.metadata + metadataRead := s.metadataRead + s.access.Unlock() + if metadataRead != nil { + return metadataRead(metadata) + } + return metadata +} +func (s *fakeRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } +func (s *fakeRuleSet) IncRef() { + s.access.Lock() + s.refs++ + afterIncrementReference := s.afterIncrementReference + s.access.Unlock() + if afterIncrementReference != nil { + afterIncrementReference() + } +} + +func (s *fakeRuleSet) DecRef() { + s.access.Lock() + beforeDecrementReference := s.beforeDecrementReference + s.access.Unlock() + if beforeDecrementReference != nil { + beforeDecrementReference() + } + s.access.Lock() + defer s.access.Unlock() + s.refs-- + if s.refs < 0 { + panic("rule-set: negative refs") + } +} +func (s *fakeRuleSet) Cleanup() {} +func (s *fakeRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.PushBack(callback) +} + +func (s *fakeRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { + s.access.Lock() + defer s.access.Unlock() + s.callbacks.Remove(element) +} +func (s *fakeRuleSet) Close() error { return nil } +func (s *fakeRuleSet) Match(metadata *adapter.InboundContext) bool { + s.access.Lock() + match := s.match + s.access.Unlock() + if match != nil { + return match(metadata) + } + return true +} +func (s *fakeRuleSet) String() string { return "fake-rule-set" } +func (s *fakeRuleSet) updateMetadata(metadata adapter.RuleSetMetadata) { + s.access.Lock() + s.metadata = metadata + callbacks := s.callbacks.Array() + s.access.Unlock() + for _, callback := range callbacks { + callback(s) + } +} + +func (s *fakeRuleSet) snapshotCallbacks() []adapter.RuleSetUpdateCallback { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.Array() +} + +func (s *fakeRuleSet) refCount() int { + s.access.Lock() + defer s.access.Unlock() + return s.refs +} + +func (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { + m.features = append(m.features, feature) +} + +func (c *fakeDNSClient) Start() {} + +func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, _ adapter.DNSQueryOptions, _ func(*mDNS.Msg) bool) (*mDNS.Msg, error) { + if c.beforeExchange != nil { + c.beforeExchange(ctx, transport, message) + } + if c.exchange == nil { + if len(message.Question) != 1 { + return nil, E.New("unused client exchange") + } + var ( + addresses []netip.Addr + response *mDNS.Msg + err error + ) + if c.lookupWithCtx != nil { + addresses, response, err = c.lookupWithCtx(ctx, transport, FqdnToDomain(message.Question[0].Name), adapter.DNSQueryOptions{}) + } else if c.lookup != nil { + addresses, response, err = c.lookup(transport, FqdnToDomain(message.Question[0].Name), adapter.DNSQueryOptions{}) + } else { + return nil, E.New("unused client exchange") + } + if err != nil { + return nil, err + } + if response != nil { + return response, nil + } + return FixedResponse(0, message.Question[0], addresses, 60), nil + } + return c.exchange(transport, message) +} + +func (c *fakeDNSClient) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool) ([]netip.Addr, error) { + if c.lookup == nil && c.lookupWithCtx == nil { + return nil, E.New("unused client lookup") + } + var ( + addresses []netip.Addr + response *mDNS.Msg + err error + ) + if c.lookupWithCtx != nil { + addresses, response, err = c.lookupWithCtx(ctx, transport, domain, options) + } else { + addresses, response, err = c.lookup(transport, domain, options) + } + if err != nil { + return nil, err + } + if response == nil { + response = FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), addresses, 60) + } + if responseChecker != nil && !responseChecker(response) { + return nil, ErrResponseRejected + } + if addresses != nil { + return addresses, nil + } + return MessageToAddresses(response), nil +} + +func (c *fakeDNSClient) ClearCache() {} + +func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { + router := newTestRouterWithContext(t, context.Background(), rules, transportManager, client) + t.Cleanup(func() { + router.Close() + }) + return router +} + +func newTestRouterWithContext(t *testing.T, ctx context.Context, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { + return newTestRouterWithContextAndLogger(t, ctx, rules, transportManager, client, log.NewNOPFactory().NewLogger("dns")) +} + +func newTestRouterWithContextAndLogger(t *testing.T, ctx context.Context, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient, dnsLogger log.ContextLogger) *Router { + t.Helper() + router := &Router{ + ctx: ctx, + logger: dnsLogger, + transport: transportManager, + client: client, + rawRules: make([]option.DNSRule, 0, len(rules)), + rules: make([]adapter.DNSRule, 0, len(rules)), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + if rules != nil { + err := router.Initialize(rules) + require.NoError(t, err) + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + } + return router +} + +func waitForLogMessageContaining(t *testing.T, entries <-chan log.Entry, done <-chan struct{}, substring string) log.Entry { + t.Helper() + timeout := time.After(time.Second) + for { + select { + case entry, ok := <-entries: + if !ok { + t.Fatal("log subscription closed") + } + if strings.Contains(entry.Message, substring) { + return entry + } + case <-done: + t.Fatal("log subscription closed") + case <-timeout: + t.Fatalf("timed out waiting for log message containing %q", substring) + } + } +} + +func fixedQuestion(name string, qType uint16) mDNS.Question { + return mDNS.Question{ + Name: mDNS.Fqdn(name), + Qtype: qType, + Qclass: mDNS.ClassINET, + } +} + +func mustRecord(t *testing.T, record string) option.DNSRecordOptions { + t.Helper() + var value option.DNSRecordOptions + require.NoError(t, value.UnmarshalJSON([]byte(`"`+record+`"`))) + return value +} + +func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + Type: C.RuleSetTypeInline, + Tag: "query-set", + InlineOptions: option.PlainRuleSet{ + Rules: []option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeA)}, + }, + }}, + }, + }) + require.NoError(t, err) + ctx = service.ContextWith[adapter.Router](ctx, &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "query-set": ruleSet, + }, + }) + + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 2), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err = router.Initialize([]option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"query-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }, + }) + require.ErrorContains(t, err, "Response Match Fields") + require.ErrorContains(t, err, "require match_response") +} + +func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + Type: C.RuleSetTypeInline, + Tag: "legacy-ipcidr-set", + InlineOptions: option.PlainRuleSet{ + Rules: []option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + IPCIDR: badoption.Listable[string]{"10.0.0.0/8"}, + }, + }}, + }, + }) + require.NoError(t, err) + ctx = service.ContextWith[adapter.Router](ctx, &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "legacy-ipcidr-set": ruleSet, + }, + }) + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"legacy-ipcidr-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, "private", transport.Tag()) + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) + return MessageToAddresses(response), response, nil + }, + }) + + require.True(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) +} + +func TestRuleSetUpdateReleasesOldRuleSetRefs(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + }) + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + + require.Equal(t, 1, fakeSet.refCount()) + + fakeSet.updateMetadata(adapter.RuleSetMetadata{}) + require.Equal(t, 1, fakeSet.refCount()) + + fakeSet.updateMetadata(adapter.RuleSetMetadata{}) + require.Equal(t, 1, fakeSet.refCount()) + + require.NoError(t, router.Close()) + require.Zero(t, fakeSet.refCount()) +} + +func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldDisableLegacyDNSMode(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil + }, + }) + require.True(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "require match_response") +} + +func TestValidateRuleSetMetadataUpdateRejectsRuleSetOnlyLegacyModeSwitchToNew(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil + }, + }) + require.True(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") +} + +func TestValidateRuleSetMetadataUpdateBeforeStartUsesStartupValidation(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 2), + rules: make([]adapter.DNSRule, 0, 2), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + }) + require.NoError(t, err) + require.False(t, router.started) + + err = router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "require match_response") +} + +func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldRequireLegacyDNSMode(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil, nil + }, + }) + require.False(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") +} + +func TestValidateRuleSetMetadataUpdateAllowsRuleSetThatKeepsNonLegacyDNSMode(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }) + require.NoError(t, err) +} + +func TestValidateRuleSetMetadataUpdateAllowsRelaxingLegacyRequirement(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil + }, + }) + require.True(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{}) + require.NoError(t, err) +} + +func TestCloseWaitsForInFlightLookupUntilContextCancellation(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + selectedTransport := &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP} + lookupStarted := make(chan struct{}) + var lookupStartedOnce sync.Once + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": selectedTransport, + }, + }, &fakeDNSClient{ + lookupWithCtx: func(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "selected", transport.Tag()) + require.Equal(t, "example.com", domain) + lookupStartedOnce.Do(func() { + close(lookupStarted) + }) + <-ctx.Done() + return nil, nil, ctx.Err() + }, + }) + + lookupCtx, cancelLookup := context.WithCancel(context.Background()) + defer cancelLookup() + var ( + lookupErr error + closeErr error + ) + lookupDone := make(chan struct{}) + go func() { + _, lookupErr = router.Lookup(lookupCtx, "example.com", adapter.DNSQueryOptions{}) + close(lookupDone) + }() + + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("lookup did not reach DNS client") + } + + closeDone := make(chan struct{}) + go func() { + closeErr = router.Close() + close(closeDone) + }() + + select { + case <-closeDone: + t.Fatal("close finished before lookup context cancellation") + default: + } + + cancelLookup() + + select { + case <-lookupDone: + case <-time.After(time.Second): + t.Fatal("lookup did not finish after cancellation") + } + select { + case <-closeDone: + case <-time.After(time.Second): + t.Fatal("close did not finish after lookup cancellation") + } + + require.ErrorIs(t, lookupErr, context.Canceled) + require.NoError(t, closeErr) +} + +func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + client := &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + switch transport.Tag() { + case "private": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) + return MessageToAddresses(response), response, nil + case "default": + t.Fatal("default transport should not be used when legacy rule matches after response") + } + return nil, nil, E.New("unexpected transport") + }, + } + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, client) + + require.True(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) +} + +func TestLookupLegacyDNSModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + var lookupAccess sync.Mutex + var lookupTags []string + recordLookup := func(tag string) { + lookupAccess.Lock() + lookupTags = append(lookupTags, tag) + lookupAccess.Unlock() + } + currentLookupTags := func() []string { + lookupAccess.Lock() + defer lookupAccess.Unlock() + return append([]string(nil), lookupTags...) + } + client := &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + recordLookup(transport.Tag()) + switch transport.Tag() { + case "private": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60) + return MessageToAddresses(response), response, nil + case "default": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60) + return MessageToAddresses(response), response, nil + } + return nil, nil, E.New("unexpected transport") + }, + } + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, client) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) + require.Equal(t, []string{"private", "default"}, currentLookupTags()) +} + +func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + Type: C.RuleSetTypeInline, + Tag: "legacy-ipcidr-set", + InlineOptions: option.PlainRuleSet{ + Rules: []option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + IPCIDR: badoption.Listable[string]{"10.0.0.0/8"}, + }, + }}, + }, + }) + require.NoError(t, err) + ctx = service.ContextWith[adapter.Router](ctx, &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "legacy-ipcidr-set": ruleSet, + }, + }) + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + var lookupAccess sync.Mutex + var lookupTags []string + recordLookup := func(tag string) { + lookupAccess.Lock() + lookupTags = append(lookupTags, tag) + lookupAccess.Unlock() + } + currentLookupTags := func() []string { + lookupAccess.Lock() + defer lookupAccess.Unlock() + return append([]string(nil), lookupTags...) + } + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"legacy-ipcidr-set"}, + RuleSetIPCIDRAcceptEmpty: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + recordLookup(transport.Tag()) + switch transport.Tag() { + case "private": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60) + return MessageToAddresses(response), response, nil + case "default": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60) + return MessageToAddresses(response), response, nil + } + return nil, nil, E.New("unexpected transport") + }, + }) + + require.True(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) + require.Equal(t, []string{"private", "default"}, currentLookupTags()) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRcodeRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeNameError, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rcode := option.DNSRCode(mDNS.RcodeNameError) + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseRcode: &rcode, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseNsRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + nsRecord := mustRecord(t, "example.com. IN NS ns1.example.com.") + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: []mDNS.Question{message.Question[0]}, + Ns: []mDNS.RR{nsRecord.Build()}, + }, nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseNs: badoption.Listable[option.DNSRecordOptions]{nsRecord}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseExtraRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + extraRecord := mustRecord(t, "ns1.example.com. IN A 192.0.2.53") + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: []mDNS.Question{message.Question[0]}, + Extra: []mDNS.RR{extraRecord.Build()}, + }, nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseExtra: badoption.Listable[option.DNSRecordOptions]{extraRecord}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + var inspectedSelected bool + client := &fakeDNSClient{ + beforeExchange: func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg) { + if transport.Tag() != "selected" { + return + } + inspectedSelected = true + metadata := adapter.ContextFrom(ctx) + require.NotNil(t, metadata) + require.Empty(t, metadata.DestinationAddresses) + require.NotNil(t, metadata.DNSResponse) + }, + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.True(t, inspectedSelected) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsResponse(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "missing"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "first-upstream": &fakeDNSTransport{tag: "first-upstream", transportType: C.DNSTypeUDP}, + "second-upstream": &fakeDNSTransport{tag: "second-upstream", transportType: C.DNSTypeUDP}, + "first-match": &fakeDNSTransport{tag: "first-match", transportType: C.DNSTypeUDP}, + "second-match": &fakeDNSTransport{tag: "second-match", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "first-upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "second-upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case "first-match": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("7.7.7.7")}, 60), nil + case "second-match": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "first-upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "second-upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "first-match"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 2.2.2.2")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "second-match"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBooleanSemantics(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + invert bool + expectedAddr netip.Addr + }{ + { + name: "plain match_response rule stays false", + expectedAddr: netip.MustParseAddr("4.4.4.4"), + }, + { + name: "invert match_response rule becomes true", + invert: true, + expectedAddr: netip.MustParseAddr("8.8.8.8"), + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return nil, E.New("upstream exchange failed") + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + Invert: testCase.invert, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{testCase.expectedAddr}, MessageToAddresses(response)) + }) + } +} + +func TestExchangeLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T) { + t.Parallel() + + var exchanges []string + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + exchanges = append(exchanges, transport.Tag()) + require.Equal(t, "upstream", transport.Tag()) + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + }, + }) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"upstream"}, exchanges) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, MessageToAddresses(response)) +} + +func TestLookupLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "upstream", transport.Tag()) + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case mDNS.TypeAAAA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + default: + return nil, E.New("unexpected qtype") + } + }, + }) + require.False(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{ + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("2001:db8::1"), + }, addresses) +} + +func TestExchangeLegacyDNSModeDisabledRespondWithoutEvaluatedResponseReturnsError(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, _ *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "upstream", transport.Tag()) + return nil, E.New("upstream exchange failed") + }, + }) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.Nil(t, response) + require.ErrorContains(t, err, dnsRespondMissingResponseMessage) +} + +func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForExchangeFailure(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, nil, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "default", transport.Tag()) + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case mDNS.TypeAAAA: + return nil, E.New("ipv6 failed") + default: + return nil, E.New("unexpected qtype") + } + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) +} + +func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForRcodeError(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, nil, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "default", transport.Tag()) + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case mDNS.TypeAAAA: + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeNameError, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil + default: + return nil, E.New("unexpected qtype") + } + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) +} + +func TestLookupLegacyDNSModeDisabledSkipsFakeIPRule(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "default", transport.Tag()) + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) +} + +func TestExchangeLegacyDNSModeDisabledAllowsRouteFakeIPRule(t *testing.T) { + t.Parallel() + + fakeTransport := &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP} + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "fake": fakeTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Same(t, fakeTransport, transport) + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("198.18.0.1")}, 60), nil + }, + }) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("198.18.0.1")}, MessageToAddresses(response)) +} + +func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }}) + require.ErrorContains(t, err, "strategy") + require.ErrorContains(t, err, "deprecated") +} + +func TestInitializeRejectsEvaluateFakeIPServerInDefaultRule(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{transports: map[string]adapter.DNSTransport{"fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}}}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}) + require.ErrorContains(t, err, "evaluate action cannot use fakeip server") + require.ErrorContains(t, err, "fake") +} + +func TestInitializeRejectsEvaluateFakeIPServerInLogicalRule(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{transports: map[string]adapter.DNSTransport{"fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}}}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}) + require.ErrorContains(t, err, "evaluate action cannot use fakeip server") + require.ErrorContains(t, err, "fake") +} + +func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchResponse(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRouteOptions, + RouteOptionsOptions: option.DNSRouteOptionsActionOptions{ + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }}) + require.ErrorContains(t, err, "strategy") + require.ErrorContains(t, err, "deprecated") +} + +func TestInitializeRejectsDNSMatchResponseWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeRejectsDNSRespondWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeRejectsLogicalDNSRespondWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeRejectsEvaluateRuleWithResponseMatchWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + }, + }, + }, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeAllowsEvaluateRuleWithResponseMatchAfterPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 2), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"bootstrap.example"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "bootstrap"}, + }, + }, + }, + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + }, + }, + }, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + }) + require.NoError(t, err) +} + +func TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodDefault, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.Nil(t, addresses) + require.Error(t, err) + require.True(t, rulepkg.IsRejected(err)) +} + +func TestExchangeLegacyDNSModeDisabledReturnsRefusedResponseForRejectAction(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodDefault, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, mDNS.RcodeRefused, response.Rcode) + require.Equal(t, []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, response.Question) +} + +func TestExchangeLegacyDNSModeDisabledReturnsDropErrorForRejectDropAction(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodDrop, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.Nil(t, response) + require.ErrorIs(t, err, tun.ErrDrop) +} + +func TestLookupLegacyDNSModeDisabledFiltersPerQueryTypeAddressesBeforeMerging(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypePredefined, + PredefinedOptions: option.DNSRouteActionPredefined{ + Answer: badoption.Listable[option.DNSRecordOptions]{ + mustRecord(t, "example.com. IN A 1.1.1.1"), + mustRecord(t, "example.com. IN AAAA 2001:db8::1"), + }, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{ + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("2001:db8::1"), + }, addresses) +} + +func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) +} + +func TestLegacyDNSModeReportsLegacyAddressFilterDeprecation(t *testing.T) { + t.Parallel() + + manager := &fakeDeprecatedManager{} + ctx := service.ContextWith[deprecated.Manager](context.Background(), manager) + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + client: &fakeDNSClient{}, + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.NoError(t, err) + + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + require.Len(t, manager.features, 1) + require.Equal(t, deprecated.OptionLegacyDNSAddressFilter.Name, manager.features[0].Name) +} + +func TestLegacyDNSModeReportsDNSRuleStrategyDeprecation(t *testing.T) { + t.Parallel() + + manager := &fakeDeprecatedManager{} + ctx := service.ContextWith[deprecated.Manager](context.Background(), manager) + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + client: &fakeDNSClient{}, + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }}) + require.NoError(t, err) + + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + require.Len(t, manager.features, 1) + require.Equal(t, deprecated.OptionLegacyDNSRuleStrategy.Name, manager.features[0].Name) +} diff --git a/dns/transport/base.go b/dns/transport/base.go new file mode 100644 index 00000000..06e41fd0 --- /dev/null +++ b/dns/transport/base.go @@ -0,0 +1,145 @@ +package transport + +import ( + "context" + "os" + "sync" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +type TransportState int + +const ( + StateNew TransportState = iota + StateStarted + StateClosing + StateClosed +) + +var ( + ErrTransportClosed = os.ErrClosed + ErrConnectionReset = E.New("connection reset") +) + +type BaseTransport struct { + dns.TransportAdapter + Logger logger.ContextLogger + + mutex sync.Mutex + state TransportState + inFlight int32 + queriesComplete chan struct{} + closeCtx context.Context + closeCancel context.CancelFunc +} + +func NewBaseTransport(adapter dns.TransportAdapter, logger logger.ContextLogger) *BaseTransport { + ctx, cancel := context.WithCancel(context.Background()) + return &BaseTransport{ + TransportAdapter: adapter, + Logger: logger, + state: StateNew, + closeCtx: ctx, + closeCancel: cancel, + } +} + +func (t *BaseTransport) State() TransportState { + t.mutex.Lock() + defer t.mutex.Unlock() + return t.state +} + +func (t *BaseTransport) SetStarted() error { + t.mutex.Lock() + defer t.mutex.Unlock() + switch t.state { + case StateNew: + t.state = StateStarted + return nil + case StateStarted: + return nil + default: + return ErrTransportClosed + } +} + +func (t *BaseTransport) BeginQuery() bool { + t.mutex.Lock() + defer t.mutex.Unlock() + if t.state != StateStarted { + return false + } + t.inFlight++ + return true +} + +func (t *BaseTransport) EndQuery() { + t.mutex.Lock() + if t.inFlight > 0 { + t.inFlight-- + } + if t.inFlight == 0 && t.queriesComplete != nil { + close(t.queriesComplete) + t.queriesComplete = nil + } + t.mutex.Unlock() +} + +func (t *BaseTransport) CloseContext() context.Context { + return t.closeCtx +} + +func (t *BaseTransport) Shutdown(ctx context.Context) error { + t.mutex.Lock() + + if t.state >= StateClosing { + t.mutex.Unlock() + return nil + } + + if t.state == StateNew { + t.state = StateClosed + t.mutex.Unlock() + t.closeCancel() + return nil + } + + t.state = StateClosing + + if t.inFlight == 0 { + t.state = StateClosed + t.mutex.Unlock() + t.closeCancel() + return nil + } + + t.queriesComplete = make(chan struct{}) + queriesComplete := t.queriesComplete + t.mutex.Unlock() + + t.closeCancel() + + select { + case <-queriesComplete: + t.mutex.Lock() + t.state = StateClosed + t.mutex.Unlock() + return nil + case <-ctx.Done(): + t.mutex.Lock() + t.state = StateClosed + t.mutex.Unlock() + return ctx.Err() + } +} + +func (t *BaseTransport) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), C.TCPTimeout) + defer cancel() + return t.Shutdown(ctx) +} diff --git a/dns/transport/connector.go b/dns/transport/connector.go new file mode 100644 index 00000000..3a87456d --- /dev/null +++ b/dns/transport/connector.go @@ -0,0 +1,321 @@ +package transport + +import ( + "context" + "net" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +type ConnectorCallbacks[T any] struct { + IsClosed func(connection T) bool + Close func(connection T) + Reset func(connection T) +} + +type Connector[T any] struct { + dial func(ctx context.Context) (T, error) + callbacks ConnectorCallbacks[T] + + access sync.Mutex + connection T + hasConnection bool + connectionCancel context.CancelFunc + connecting chan struct{} + + closeCtx context.Context + closed bool +} + +func NewConnector[T any](closeCtx context.Context, dial func(context.Context) (T, error), callbacks ConnectorCallbacks[T]) *Connector[T] { + return &Connector[T]{ + dial: dial, + callbacks: callbacks, + closeCtx: closeCtx, + } +} + +func NewSingleflightConnector(closeCtx context.Context, dial func(context.Context) (*Connection, error)) *Connector[*Connection] { + return NewConnector(closeCtx, dial, ConnectorCallbacks[*Connection]{ + IsClosed: func(connection *Connection) bool { + return connection.IsClosed() + }, + Close: func(connection *Connection) { + connection.CloseWithError(ErrTransportClosed) + }, + Reset: func(connection *Connection) { + connection.CloseWithError(ErrConnectionReset) + }, + }) +} + +type contextKeyConnecting struct{} + +var errRecursiveConnectorDial = E.New("recursive connector dial") + +type connectorDialResult[T any] struct { + connection T + cancel context.CancelFunc + err error +} + +func (c *Connector[T]) Get(ctx context.Context) (T, error) { + var zero T + for { + c.access.Lock() + + if c.closed { + c.access.Unlock() + return zero, ErrTransportClosed + } + + if c.hasConnection && !c.callbacks.IsClosed(c.connection) { + connection := c.connection + c.access.Unlock() + return connection, nil + } + + c.hasConnection = false + if c.connectionCancel != nil { + c.connectionCancel() + c.connectionCancel = nil + } + if isRecursiveConnectorDial(ctx, c) { + c.access.Unlock() + return zero, errRecursiveConnectorDial + } + + if c.connecting != nil { + connecting := c.connecting + c.access.Unlock() + + select { + case <-connecting: + continue + case <-ctx.Done(): + return zero, ctx.Err() + case <-c.closeCtx.Done(): + return zero, ErrTransportClosed + } + } + + if err := ctx.Err(); err != nil { + c.access.Unlock() + return zero, err + } + + connecting := make(chan struct{}) + c.connecting = connecting + dialContext := context.WithValue(ctx, contextKeyConnecting{}, c) + dialResult := make(chan connectorDialResult[T], 1) + c.access.Unlock() + + go func() { + connection, cancel, err := c.dialWithCancellation(dialContext) + dialResult <- connectorDialResult[T]{ + connection: connection, + cancel: cancel, + err: err, + } + }() + + select { + case result := <-dialResult: + return c.completeDial(ctx, connecting, result) + case <-ctx.Done(): + go func() { + result := <-dialResult + _, _ = c.completeDial(ctx, connecting, result) + }() + return zero, ctx.Err() + case <-c.closeCtx.Done(): + go func() { + result := <-dialResult + _, _ = c.completeDial(ctx, connecting, result) + }() + return zero, ErrTransportClosed + } + } +} + +func isRecursiveConnectorDial[T any](ctx context.Context, connector *Connector[T]) bool { + dialConnector, loaded := ctx.Value(contextKeyConnecting{}).(*Connector[T]) + return loaded && dialConnector == connector +} + +func (c *Connector[T]) completeDial(ctx context.Context, connecting chan struct{}, result connectorDialResult[T]) (T, error) { + var zero T + + c.access.Lock() + defer c.access.Unlock() + defer func() { + if c.connecting == connecting { + c.connecting = nil + } + close(connecting) + }() + + if result.err != nil { + return zero, result.err + } + if c.closed || c.closeCtx.Err() != nil { + result.cancel() + c.callbacks.Close(result.connection) + return zero, ErrTransportClosed + } + if err := ctx.Err(); err != nil { + result.cancel() + c.callbacks.Close(result.connection) + return zero, err + } + + c.connection = result.connection + c.hasConnection = true + c.connectionCancel = result.cancel + return c.connection, nil +} + +func (c *Connector[T]) dialWithCancellation(ctx context.Context) (T, context.CancelFunc, error) { + var zero T + if err := ctx.Err(); err != nil { + return zero, nil, err + } + connCtx, cancel := context.WithCancel(c.closeCtx) + + var ( + stateAccess sync.Mutex + dialComplete bool + ) + stopCancel := context.AfterFunc(ctx, func() { + stateAccess.Lock() + if !dialComplete { + cancel() + } + stateAccess.Unlock() + }) + select { + case <-ctx.Done(): + stateAccess.Lock() + dialComplete = true + stateAccess.Unlock() + stopCancel() + cancel() + return zero, nil, ctx.Err() + default: + } + + connection, err := c.dial(valueContext{connCtx, ctx}) + stateAccess.Lock() + dialComplete = true + stateAccess.Unlock() + stopCancel() + if err != nil { + cancel() + return zero, nil, err + } + return connection, cancel, nil +} + +type valueContext struct { + context.Context + parent context.Context +} + +func (v valueContext) Value(key any) any { + return v.parent.Value(key) +} + +func (v valueContext) Deadline() (time.Time, bool) { + return v.parent.Deadline() +} + +func (c *Connector[T]) Close() error { + c.access.Lock() + defer c.access.Unlock() + + if c.closed { + return nil + } + c.closed = true + + if c.connectionCancel != nil { + c.connectionCancel() + c.connectionCancel = nil + } + if c.hasConnection { + c.callbacks.Close(c.connection) + c.hasConnection = false + } + + return nil +} + +func (c *Connector[T]) Reset() { + c.access.Lock() + defer c.access.Unlock() + + if c.connectionCancel != nil { + c.connectionCancel() + c.connectionCancel = nil + } + if c.hasConnection { + c.callbacks.Reset(c.connection) + c.hasConnection = false + } +} + +type Connection struct { + net.Conn + + closeOnce sync.Once + done chan struct{} + closeError error +} + +func WrapConnection(conn net.Conn) *Connection { + return &Connection{ + Conn: conn, + done: make(chan struct{}), + } +} + +func (c *Connection) Done() <-chan struct{} { + return c.done +} + +func (c *Connection) IsClosed() bool { + select { + case <-c.done: + return true + default: + return false + } +} + +func (c *Connection) CloseError() error { + select { + case <-c.done: + if c.closeError != nil { + return c.closeError + } + return ErrTransportClosed + default: + return nil + } +} + +func (c *Connection) Close() error { + return c.CloseWithError(ErrTransportClosed) +} + +func (c *Connection) CloseWithError(err error) error { + var returnError error + c.closeOnce.Do(func() { + c.closeError = err + returnError = c.Conn.Close() + close(c.done) + }) + return returnError +} diff --git a/dns/transport/connector_test.go b/dns/transport/connector_test.go new file mode 100644 index 00000000..309b28c8 --- /dev/null +++ b/dns/transport/connector_test.go @@ -0,0 +1,407 @@ +package transport + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type testConnectorConnection struct{} + +func TestConnectorRecursiveGetFailsFast(t *testing.T) { + t.Parallel() + + var ( + dialCount atomic.Int32 + closeCount atomic.Int32 + connector *Connector[*testConnectorConnection] + ) + + dial := func(ctx context.Context) (*testConnectorConnection, error) { + dialCount.Add(1) + _, err := connector.Get(ctx) + if err != nil { + return nil, err + } + return &testConnectorConnection{}, nil + } + + connector = NewConnector(context.Background(), dial, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) { + closeCount.Add(1) + }, + Reset: func(connection *testConnectorConnection) { + closeCount.Add(1) + }, + }) + + _, err := connector.Get(context.Background()) + require.ErrorIs(t, err, errRecursiveConnectorDial) + require.EqualValues(t, 1, dialCount.Load()) + require.EqualValues(t, 0, closeCount.Load()) +} + +func TestConnectorRecursiveGetAcrossConnectorsAllowed(t *testing.T) { + t.Parallel() + + var ( + outerDialCount atomic.Int32 + innerDialCount atomic.Int32 + outerConnector *Connector[*testConnectorConnection] + innerConnector *Connector[*testConnectorConnection] + ) + + innerConnector = NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + innerDialCount.Add(1) + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) {}, + Reset: func(connection *testConnectorConnection) {}, + }) + + outerConnector = NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + outerDialCount.Add(1) + _, err := innerConnector.Get(ctx) + if err != nil { + return nil, err + } + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) {}, + Reset: func(connection *testConnectorConnection) {}, + }) + + _, err := outerConnector.Get(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 1, outerDialCount.Load()) + require.EqualValues(t, 1, innerDialCount.Load()) +} + +func TestConnectorDialContextPreservesValueAndDeadline(t *testing.T) { + t.Parallel() + + type contextKey struct{} + + var ( + dialValue any + dialDeadline time.Time + dialHasDeadline bool + ) + + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + dialValue = ctx.Value(contextKey{}) + dialDeadline, dialHasDeadline = ctx.Deadline() + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) {}, + Reset: func(connection *testConnectorConnection) {}, + }) + + deadline := time.Now().Add(time.Minute) + requestContext, cancel := context.WithDeadline(context.WithValue(context.Background(), contextKey{}, "test-value"), deadline) + defer cancel() + + _, err := connector.Get(requestContext) + require.NoError(t, err) + require.Equal(t, "test-value", dialValue) + require.True(t, dialHasDeadline) + require.WithinDuration(t, deadline, dialDeadline, time.Second) +} + +func TestConnectorDialSkipsCanceledRequest(t *testing.T) { + t.Parallel() + + var dialCount atomic.Int32 + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + dialCount.Add(1) + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) {}, + Reset: func(connection *testConnectorConnection) {}, + }) + + requestContext, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := connector.Get(requestContext) + require.ErrorIs(t, err, context.Canceled) + require.EqualValues(t, 0, dialCount.Load()) +} + +func TestConnectorCanceledRequestDoesNotCacheConnection(t *testing.T) { + t.Parallel() + + var ( + dialCount atomic.Int32 + closeCount atomic.Int32 + ) + dialStarted := make(chan struct{}, 1) + releaseDial := make(chan struct{}) + + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + dialCount.Add(1) + select { + case dialStarted <- struct{}{}: + default: + } + <-releaseDial + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) { + closeCount.Add(1) + }, + Reset: func(connection *testConnectorConnection) {}, + }) + + requestContext, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := connector.Get(requestContext) + result <- err + }() + + <-dialStarted + cancel() + close(releaseDial) + + err := <-result + require.ErrorIs(t, err, context.Canceled) + require.EqualValues(t, 1, dialCount.Load()) + require.Eventually(t, func() bool { + return closeCount.Load() == 1 + }, time.Second, 10*time.Millisecond) + + _, err = connector.Get(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 2, dialCount.Load()) +} + +func TestConnectorCanceledRequestReturnsBeforeIgnoredDialCompletes(t *testing.T) { + t.Parallel() + + var ( + dialCount atomic.Int32 + closeCount atomic.Int32 + ) + dialStarted := make(chan struct{}, 1) + releaseDial := make(chan struct{}) + + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + dialCount.Add(1) + select { + case dialStarted <- struct{}{}: + default: + } + <-releaseDial + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) { + closeCount.Add(1) + }, + Reset: func(connection *testConnectorConnection) {}, + }) + + requestContext, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := connector.Get(requestContext) + result <- err + }() + + <-dialStarted + cancel() + + select { + case err := <-result: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("Get did not return after request cancel") + } + + require.EqualValues(t, 1, dialCount.Load()) + require.EqualValues(t, 0, closeCount.Load()) + + close(releaseDial) + + require.Eventually(t, func() bool { + return closeCount.Load() == 1 + }, time.Second, 10*time.Millisecond) + + _, err := connector.Get(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 2, dialCount.Load()) +} + +func TestConnectorWaiterDoesNotStartNewDialBeforeCanceledDialCompletes(t *testing.T) { + t.Parallel() + + var ( + dialCount atomic.Int32 + closeCount atomic.Int32 + ) + firstDialStarted := make(chan struct{}, 1) + secondDialStarted := make(chan struct{}, 1) + releaseFirstDial := make(chan struct{}) + + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + attempt := dialCount.Add(1) + switch attempt { + case 1: + select { + case firstDialStarted <- struct{}{}: + default: + } + <-releaseFirstDial + case 2: + select { + case secondDialStarted <- struct{}{}: + default: + } + } + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) { + closeCount.Add(1) + }, + Reset: func(connection *testConnectorConnection) {}, + }) + + requestContext, cancel := context.WithCancel(context.Background()) + firstResult := make(chan error, 1) + go func() { + _, err := connector.Get(requestContext) + firstResult <- err + }() + + <-firstDialStarted + cancel() + + secondResult := make(chan error, 1) + go func() { + _, err := connector.Get(context.Background()) + secondResult <- err + }() + + select { + case <-secondDialStarted: + t.Fatal("second dial started before first dial completed") + case <-time.After(100 * time.Millisecond): + } + + select { + case err := <-firstResult: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("first Get did not return after request cancel") + } + + close(releaseFirstDial) + + require.Eventually(t, func() bool { + return closeCount.Load() == 1 + }, time.Second, 10*time.Millisecond) + + select { + case <-secondDialStarted: + case <-time.After(time.Second): + t.Fatal("second dial did not start after first dial completed") + } + + err := <-secondResult + require.NoError(t, err) + require.EqualValues(t, 2, dialCount.Load()) +} + +func TestConnectorDialContextNotCanceledByRequestContextAfterDial(t *testing.T) { + t.Parallel() + + var dialContext context.Context + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + dialContext = ctx + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) {}, + Reset: func(connection *testConnectorConnection) {}, + }) + + requestContext, cancel := context.WithCancel(context.Background()) + _, err := connector.Get(requestContext) + require.NoError(t, err) + require.NotNil(t, dialContext) + + cancel() + + select { + case <-dialContext.Done(): + t.Fatal("dial context canceled by request context after successful dial") + case <-time.After(100 * time.Millisecond): + } + + err = connector.Close() + require.NoError(t, err) +} + +func TestConnectorDialContextCanceledOnClose(t *testing.T) { + t.Parallel() + + var dialContext context.Context + connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { + dialContext = ctx + return &testConnectorConnection{}, nil + }, ConnectorCallbacks[*testConnectorConnection]{ + IsClosed: func(connection *testConnectorConnection) bool { + return false + }, + Close: func(connection *testConnectorConnection) {}, + Reset: func(connection *testConnectorConnection) {}, + }) + + _, err := connector.Get(context.Background()) + require.NoError(t, err) + require.NotNil(t, dialContext) + + select { + case <-dialContext.Done(): + t.Fatal("dial context canceled before connector close") + default: + } + + err = connector.Close() + require.NoError(t, err) + + select { + case <-dialContext.Done(): + case <-time.After(time.Second): + t.Fatal("dial context not canceled after connector close") + } +} diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go new file mode 100644 index 00000000..3f4eb721 --- /dev/null +++ b/dns/transport/dhcp/dhcp.go @@ -0,0 +1,310 @@ +package dhcp + +import ( + "context" + "errors" + "io" + "net" + "runtime" + "strings" + "sync" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + "github.com/insomniacslk/dhcp/dhcpv4" + mDNS "github.com/miekg/dns" + "golang.org/x/exp/slices" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.DHCPDNSServerOptions](registry, C.DNSTypeDHCP, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + dialer N.Dialer + logger logger.ContextLogger + networkManager adapter.NetworkManager + interfaceName string + interfaceCallback *list.Element[tun.DefaultInterfaceUpdateCallback] + transportLock sync.RWMutex + updatedAt time.Time + lastError error + servers []M.Socksaddr + search []string + ndots int + attempts int +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.DHCPDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewLocalDialer(ctx, options.LocalDNSServerOptions) + if err != nil { + return nil, err + } + return &Transport{ + TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeDHCP, tag, options.LocalDNSServerOptions), + ctx: ctx, + dialer: transportDialer, + logger: logger, + networkManager: service.FromContext[adapter.NetworkManager](ctx), + interfaceName: options.Interface, + ndots: 1, + attempts: 2, + }, nil +} + +func NewRawTransport(transportAdapter dns.TransportAdapter, ctx context.Context, dialer N.Dialer, logger log.ContextLogger) *Transport { + return &Transport{ + TransportAdapter: transportAdapter, + ctx: ctx, + dialer: dialer, + logger: logger, + networkManager: service.FromContext[adapter.NetworkManager](ctx), + ndots: 1, + attempts: 2, + } +} + +func (t *Transport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if t.interfaceName == "" { + t.interfaceCallback = t.networkManager.InterfaceMonitor().RegisterCallback(t.interfaceUpdated) + } + go func() { + _, err := t.fetch() + if err != nil { + t.logger.Error(E.Cause(err, "fetch DNS servers")) + } + }() + return nil +} + +func (t *Transport) Close() error { + if t.interfaceCallback != nil { + t.networkManager.InterfaceMonitor().UnregisterCallback(t.interfaceCallback) + } + return nil +} + +func (t *Transport) Reset() { + t.transportLock.Lock() + t.updatedAt = time.Time{} + t.servers = nil + t.transportLock.Unlock() +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + servers, err := t.fetch() + if err != nil { + return nil, err + } + if len(servers) == 0 { + return nil, E.New("dhcp: empty DNS servers from response") + } + return t.Exchange0(ctx, message, servers) +} + +func (t *Transport) Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) { + question := message.Question[0] + domain := dns.FqdnToDomain(question.Name) + if len(servers) == 1 || !(message.Question[0].Qtype == mDNS.TypeA || message.Question[0].Qtype == mDNS.TypeAAAA) { + return t.exchangeSingleRequest(ctx, servers, message, domain) + } else { + return t.exchangeParallel(ctx, servers, message, domain) + } +} + +func (t *Transport) Fetch() []M.Socksaddr { + servers, _ := t.fetch() + return servers +} + +func (t *Transport) fetch() ([]M.Socksaddr, error) { + t.transportLock.RLock() + updatedAt := t.updatedAt + lastError := t.lastError + servers := t.servers + t.transportLock.RUnlock() + if lastError != nil { + return nil, lastError + } + if time.Since(updatedAt) < C.DHCPTTL { + return servers, nil + } + t.transportLock.Lock() + defer t.transportLock.Unlock() + if time.Since(t.updatedAt) < C.DHCPTTL { + return t.servers, nil + } + err := t.updateServers() + if err != nil { + return servers, err + } + return t.servers, nil +} + +func (t *Transport) fetchInterface() (*control.Interface, error) { + if t.interfaceName == "" { + if t.networkManager.InterfaceMonitor() == nil { + return nil, E.New("missing monitor for auto DHCP, set route.auto_detect_interface") + } + defaultInterface := t.networkManager.InterfaceMonitor().DefaultInterface() + if defaultInterface == nil { + return nil, E.New("missing default interface") + } + return defaultInterface, nil + } else { + return t.networkManager.InterfaceFinder().ByName(t.interfaceName) + } +} + +func (t *Transport) updateServers() error { + iface, err := t.fetchInterface() + if err != nil { + return E.Cause(err, "dhcp: prepare interface") + } + + t.logger.Info("dhcp: query DNS servers on ", iface.Name) + fetchCtx, cancel := context.WithTimeout(t.ctx, C.DHCPTimeout) + err = t.fetchServers0(fetchCtx, iface) + cancel() + t.updatedAt = time.Now() + if err != nil { + t.lastError = err + return err + } else if len(t.servers) == 0 { + t.lastError = E.New("dhcp: empty DNS servers response") + return t.lastError + } else { + t.lastError = nil + return nil + } +} + +func (t *Transport) interfaceUpdated(defaultInterface *control.Interface, flags int) { + err := t.updateServers() + if err != nil { + t.logger.Error("update servers: ", err) + } +} + +func (t *Transport) fetchServers0(ctx context.Context, iface *control.Interface) error { + var listener net.ListenConfig + listener.Control = control.Append(listener.Control, control.BindToInterface(t.networkManager.InterfaceFinder(), iface.Name, iface.Index)) + listener.Control = control.Append(listener.Control, control.ReuseAddr()) + listenAddr := "0.0.0.0:68" + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + listenAddr = "255.255.255.255:68" + } + var ( + packetConn net.PacketConn + err error + ) + for i := 0; i < 5; i++ { + packetConn, err = listener.ListenPacket(t.ctx, "udp4", listenAddr) + if err == nil || !errors.Is(err, syscall.EADDRINUSE) { + break + } + time.Sleep(time.Second) + } + if err != nil { + return err + } + defer packetConn.Close() + + discovery, err := dhcpv4.NewDiscovery(iface.HardwareAddr, dhcpv4.WithBroadcast(true), dhcpv4.WithRequestedOptions( + dhcpv4.OptionDomainName, + dhcpv4.OptionDomainNameServer, + dhcpv4.OptionDNSDomainSearchList, + )) + if err != nil { + return err + } + + _, err = packetConn.WriteTo(discovery.ToBytes(), &net.UDPAddr{IP: net.IPv4bcast, Port: 67}) + if err != nil { + return err + } + + var group task.Group + group.Append0(func(ctx context.Context) error { + return t.fetchServersResponse(iface, packetConn, discovery.TransactionID) + }) + group.Cleanup(func() { + packetConn.Close() + }) + return group.Run(ctx) +} + +func (t *Transport) fetchServersResponse(iface *control.Interface, packetConn net.PacketConn, transactionID dhcpv4.TransactionID) error { + buffer := buf.NewSize(dhcpv4.MaxMessageSize) + defer buffer.Release() + + for { + buffer.Reset() + _, _, err := buffer.ReadPacketFrom(packetConn) + if err != nil { + if errors.Is(err, io.ErrShortBuffer) { + continue + } + return err + } + + dhcpPacket, err := dhcpv4.FromBytes(buffer.Bytes()) + if err != nil { + t.logger.Trace("dhcp: parse DHCP response: ", err) + return err + } + + if dhcpPacket.MessageType() != dhcpv4.MessageTypeOffer { + t.logger.Trace("dhcp: expected OFFER response, but got ", dhcpPacket.MessageType()) + continue + } + + if dhcpPacket.TransactionID != transactionID { + t.logger.Trace("dhcp: expected transaction ID ", transactionID, ", but got ", dhcpPacket.TransactionID) + continue + } + + return t.recreateServers(iface, dhcpPacket) + } +} + +func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4.DHCPv4) error { + searchList := dhcpPacket.DomainSearch() + if searchList != nil && len(searchList.Labels) > 0 { + t.search = searchList.Labels + } else if dhcpPacket.DomainName() != "" { + t.search = []string{dhcpPacket.DomainName()} + } + serverAddrs := common.Map(dhcpPacket.DNS(), func(it net.IP) M.Socksaddr { + return M.SocksaddrFrom(M.AddrFromIP(it), 53) + }) + if len(serverAddrs) > 0 && !slices.Equal(t.servers, serverAddrs) { + t.logger.Info("dhcp: updated DNS servers from ", iface.Name, ": [", strings.Join(common.Map(serverAddrs, M.Socksaddr.String), ","), "], search: [", strings.Join(t.search, ","), "]") + } + t.servers = serverAddrs + return nil +} diff --git a/dns/transport/dhcp/dhcp_shared.go b/dns/transport/dhcp/dhcp_shared.go new file mode 100644 index 00000000..20cd50c5 --- /dev/null +++ b/dns/transport/dhcp/dhcp_shared.go @@ -0,0 +1,205 @@ +package dhcp + +import ( + "context" + "errors" + "math/rand" + "strings" + "syscall" + + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +func (t *Transport) exchangeSingleRequest(ctx context.Context, servers []M.Socksaddr, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { + var lastErr error + for _, fqdn := range t.nameList(domain) { + response, err := t.tryOneName(ctx, servers, fqdn, message) + if err != nil { + lastErr = err + continue + } + return response, nil + } + return nil, lastErr +} + +func (t *Transport) exchangeParallel(ctx context.Context, servers []M.Socksaddr, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { + returned := make(chan struct{}) + defer close(returned) + type queryResult struct { + response *mDNS.Msg + err error + } + results := make(chan queryResult) + startRacer := func(ctx context.Context, fqdn string) { + response, err := t.tryOneName(ctx, servers, fqdn, message) + select { + case results <- queryResult{response, err}: + case <-returned: + } + } + queryCtx, queryCancel := context.WithCancel(ctx) + defer queryCancel() + var nameCount int + for _, fqdn := range t.nameList(domain) { + nameCount++ + go startRacer(queryCtx, fqdn) + } + var errors []error + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-results: + if result.err == nil { + return result.response, nil + } + errors = append(errors, result.err) + if len(errors) == nameCount { + return nil, E.Errors(errors...) + } + } + } +} + +func (t *Transport) tryOneName(ctx context.Context, servers []M.Socksaddr, fqdn string, message *mDNS.Msg) (*mDNS.Msg, error) { + sLen := len(servers) + var lastErr error + for i := 0; i < t.attempts; i++ { + for j := 0; j < sLen; j++ { + server := servers[j] + question := message.Question[0] + question.Name = fqdn + response, err := t.exchangeOne(ctx, server, question) + if err != nil { + lastErr = err + continue + } + return response, nil + } + } + return nil, E.Cause(lastErr, fqdn) +} + +func (t *Transport) exchangeOne(ctx context.Context, server M.Socksaddr, question mDNS.Question) (*mDNS.Msg, error) { + if server.Port == 0 { + server.Port = 53 + } + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: uint16(rand.Uint32()), + RecursionDesired: true, + AuthenticatedData: true, + }, + Question: []mDNS.Question{question}, + Compress: true, + } + request.SetEdns0(buf.UDPBufferSize, false) + return t.exchangeUDP(ctx, server, request) +} + +func (t *Transport) exchangeUDP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, server) + if err != nil { + return nil, err + } + defer conn.Close() + if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { + conn.SetDeadline(deadline) + } + buffer := buf.Get(buf.UDPBufferSize) + defer buf.Put(buffer) + rawMessage, err := request.PackBuffer(buffer) + if err != nil { + return nil, E.Cause(err, "pack request") + } + _, err = conn.Write(rawMessage) + if err != nil { + if errors.Is(err, syscall.EMSGSIZE) { + return t.exchangeTCP(ctx, server, request) + } + return nil, E.Cause(err, "write request") + } + n, err := conn.Read(buffer) + if err != nil { + if errors.Is(err, syscall.EMSGSIZE) { + return t.exchangeTCP(ctx, server, request) + } + return nil, E.Cause(err, "read response") + } + var response mDNS.Msg + err = response.Unpack(buffer[:n]) + if err != nil { + return nil, E.Cause(err, "unpack response") + } + if response.Truncated { + return t.exchangeTCP(ctx, server, request) + } + return &response, nil +} + +func (t *Transport) exchangeTCP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, server) + if err != nil { + return nil, err + } + defer conn.Close() + if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { + conn.SetDeadline(deadline) + } + err = transport.WriteMessage(conn, 0, request) + if err != nil { + return nil, err + } + return transport.ReadMessage(conn) +} + +func (t *Transport) nameList(name string) []string { + l := len(name) + rooted := l > 0 && name[l-1] == '.' + if l > 254 || l == 254 && !rooted { + return nil + } + + if rooted { + if avoidDNS(name) { + return nil + } + return []string{name} + } + + hasNdots := strings.Count(name, ".") >= t.ndots + name += "." + // l++ + + names := make([]string, 0, 1+len(t.search)) + if hasNdots && !avoidDNS(name) { + names = append(names, name) + } + for _, suffix := range t.search { + fqdn := name + suffix + if !avoidDNS(fqdn) && len(fqdn) <= 254 { + names = append(names, fqdn) + } + } + if !hasNdots && !avoidDNS(name) { + names = append(names, name) + } + return names +} + +func avoidDNS(name string) bool { + if name == "" { + return true + } + if name[len(name)-1] == '.' { + name = name[:len(name)-1] + } + return strings.HasSuffix(name, ".onion") +} diff --git a/dns/transport/fakeip/fakeip.go b/dns/transport/fakeip/fakeip.go new file mode 100644 index 00000000..9aa41e58 --- /dev/null +++ b/dns/transport/fakeip/fakeip.go @@ -0,0 +1,79 @@ +package fakeip + +import ( + "context" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.FakeIPDNSServerOptions](registry, C.DNSTypeFakeIP, NewTransport) +} + +var _ adapter.FakeIPTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + logger logger.ContextLogger + store adapter.FakeIPStore + inet4Enabled bool + inet6Enabled bool +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.FakeIPDNSServerOptions) (adapter.DNSTransport, error) { + inet4Range := options.Inet4Range.Build(netip.Prefix{}) + inet6Range := options.Inet6Range.Build(netip.Prefix{}) + if !inet4Range.IsValid() && !inet6Range.IsValid() { + return nil, E.New("at least one of inet4_range or inet6_range must be set") + } + store := NewStore(ctx, logger, inet4Range, inet6Range) + return &Transport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeFakeIP, tag, nil), + logger: logger, + store: store, + inet4Enabled: inet4Range.IsValid(), + inet6Enabled: inet6Range.IsValid(), + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return t.store.Start() +} + +func (t *Transport) Close() error { + return t.store.Close() +} + +func (t *Transport) Reset() { +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + if question.Qtype != mDNS.TypeA && question.Qtype != mDNS.TypeAAAA { + return nil, E.New("only IP queries are supported by fakeip") + } + if question.Qtype == mDNS.TypeA && !t.inet4Enabled || question.Qtype == mDNS.TypeAAAA && !t.inet6Enabled { + return dns.FixedResponseStatus(message, mDNS.RcodeSuccess), nil + } + address, err := t.store.Create(dns.FqdnToDomain(question.Name), question.Qtype == mDNS.TypeAAAA) + if err != nil { + return nil, err + } + return dns.FixedResponse(message.Id, question, []netip.Addr{address}, C.DefaultDNSTTL), nil +} + +func (t *Transport) Store() adapter.FakeIPStore { + return t.store +} diff --git a/dns/transport/fakeip/memory.go b/dns/transport/fakeip/memory.go new file mode 100644 index 00000000..0cf8ecc7 --- /dev/null +++ b/dns/transport/fakeip/memory.go @@ -0,0 +1,93 @@ +package fakeip + +import ( + "net/netip" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.FakeIPStorage = (*MemoryStorage)(nil) + +type MemoryStorage struct { + addressAccess sync.RWMutex + domainAccess sync.RWMutex + addressCache map[netip.Addr]string + domainCache4 map[string]netip.Addr + domainCache6 map[string]netip.Addr +} + +func NewMemoryStorage() *MemoryStorage { + return &MemoryStorage{ + addressCache: make(map[netip.Addr]string), + domainCache4: make(map[string]netip.Addr), + domainCache6: make(map[string]netip.Addr), + } +} + +func (s *MemoryStorage) FakeIPMetadata() *adapter.FakeIPMetadata { + return nil +} + +func (s *MemoryStorage) FakeIPSaveMetadata(metadata *adapter.FakeIPMetadata) error { + return nil +} + +func (s *MemoryStorage) FakeIPSaveMetadataAsync(metadata *adapter.FakeIPMetadata) { +} + +func (s *MemoryStorage) FakeIPStore(address netip.Addr, domain string) error { + s.addressAccess.Lock() + s.domainAccess.Lock() + if oldDomain, loaded := s.addressCache[address]; loaded { + if address.Is4() { + delete(s.domainCache4, oldDomain) + } else { + delete(s.domainCache6, oldDomain) + } + } + s.addressCache[address] = domain + if address.Is4() { + s.domainCache4[domain] = address + } else { + s.domainCache6[domain] = address + } + s.domainAccess.Unlock() + s.addressAccess.Unlock() + return nil +} + +func (s *MemoryStorage) FakeIPStoreAsync(address netip.Addr, domain string, logger logger.Logger) { + _ = s.FakeIPStore(address, domain) +} + +func (s *MemoryStorage) FakeIPLoad(address netip.Addr) (string, bool) { + s.addressAccess.RLock() + defer s.addressAccess.RUnlock() + domain, loaded := s.addressCache[address] + return domain, loaded +} + +func (s *MemoryStorage) FakeIPLoadDomain(domain string, isIPv6 bool) (netip.Addr, bool) { + s.domainAccess.RLock() + defer s.domainAccess.RUnlock() + if !isIPv6 { + address, loaded := s.domainCache4[domain] + return address, loaded + } else { + address, loaded := s.domainCache6[domain] + return address, loaded + } +} + +func (s *MemoryStorage) FakeIPReset() error { + s.addressAccess.Lock() + s.domainAccess.Lock() + s.addressCache = make(map[netip.Addr]string) + s.domainCache4 = make(map[string]netip.Addr) + s.domainCache6 = make(map[string]netip.Addr) + s.domainAccess.Unlock() + s.addressAccess.Unlock() + return nil +} diff --git a/dns/transport/fakeip/store.go b/dns/transport/fakeip/store.go new file mode 100644 index 00000000..b7c51dfa --- /dev/null +++ b/dns/transport/fakeip/store.go @@ -0,0 +1,161 @@ +package fakeip + +import ( + "context" + "net/netip" + "sync" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" +) + +var _ adapter.FakeIPStore = (*Store)(nil) + +type Store struct { + ctx context.Context + logger logger.Logger + inet4Range netip.Prefix + inet6Range netip.Prefix + inet4Last netip.Addr + inet6Last netip.Addr + storage adapter.FakeIPStorage + + addressAccess sync.Mutex + inet4Current netip.Addr + inet6Current netip.Addr +} + +func NewStore(ctx context.Context, logger logger.Logger, inet4Range netip.Prefix, inet6Range netip.Prefix) *Store { + store := &Store{ + ctx: ctx, + logger: logger, + inet4Range: inet4Range, + inet6Range: inet6Range, + } + if inet4Range.IsValid() { + store.inet4Last = broadcastAddress(inet4Range) + } + if inet6Range.IsValid() { + store.inet6Last = broadcastAddress(inet6Range) + } + return store +} + +func broadcastAddress(prefix netip.Prefix) netip.Addr { + addr := prefix.Addr() + raw := addr.As16() + bits := prefix.Bits() + if addr.Is4() { + bits += 96 + } + for i := bits; i < 128; i++ { + raw[i/8] |= 1 << (7 - i%8) + } + if addr.Is4() { + return netip.AddrFrom4([4]byte(raw[12:])) + } + return netip.AddrFrom16(raw) +} + +func (s *Store) Start() error { + var storage adapter.FakeIPStorage + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil && cacheFile.StoreFakeIP() { + storage = cacheFile + } + if storage == nil { + storage = NewMemoryStorage() + } + metadata := storage.FakeIPMetadata() + if metadata != nil && metadata.Inet4Range == s.inet4Range && metadata.Inet6Range == s.inet6Range { + s.inet4Current = metadata.Inet4Current + s.inet6Current = metadata.Inet6Current + } else { + if s.inet4Range.IsValid() { + s.inet4Current = s.inet4Range.Addr().Next() + } + if s.inet6Range.IsValid() { + s.inet6Current = s.inet6Range.Addr().Next() + } + _ = storage.FakeIPReset() + } + s.storage = storage + return nil +} + +func (s *Store) Contains(address netip.Addr) bool { + return s.inet4Range.Contains(address) || s.inet6Range.Contains(address) +} + +func (s *Store) Close() error { + if s.storage == nil { + return nil + } + s.addressAccess.Lock() + metadata := &adapter.FakeIPMetadata{ + Inet4Range: s.inet4Range, + Inet6Range: s.inet6Range, + Inet4Current: s.inet4Current, + Inet6Current: s.inet6Current, + } + s.addressAccess.Unlock() + return s.storage.FakeIPSaveMetadata(metadata) +} + +func (s *Store) Create(domain string, isIPv6 bool) (netip.Addr, error) { + if address, loaded := s.storage.FakeIPLoadDomain(domain, isIPv6); loaded { + return address, nil + } + + s.addressAccess.Lock() + defer s.addressAccess.Unlock() + + // Double-check after acquiring lock + if address, loaded := s.storage.FakeIPLoadDomain(domain, isIPv6); loaded { + return address, nil + } + + var address netip.Addr + if !isIPv6 { + if !s.inet4Current.IsValid() { + return netip.Addr{}, E.New("missing IPv4 fakeip address range") + } + nextAddress := s.inet4Current.Next() + if nextAddress == s.inet4Last || !s.inet4Range.Contains(nextAddress) { + nextAddress = s.inet4Range.Addr().Next().Next() + } + s.inet4Current = nextAddress + address = nextAddress + } else { + if !s.inet6Current.IsValid() { + return netip.Addr{}, E.New("missing IPv6 fakeip address range") + } + nextAddress := s.inet6Current.Next() + if nextAddress == s.inet6Last || !s.inet6Range.Contains(nextAddress) { + nextAddress = s.inet6Range.Addr().Next().Next() + } + s.inet6Current = nextAddress + address = nextAddress + } + err := s.storage.FakeIPStore(address, domain) + if err != nil { + s.logger.Warn("save FakeIP cache: ", err) + } + s.storage.FakeIPSaveMetadataAsync(&adapter.FakeIPMetadata{ + Inet4Range: s.inet4Range, + Inet6Range: s.inet6Range, + Inet4Current: s.inet4Current, + Inet6Current: s.inet6Current, + }) + return address, nil +} + +func (s *Store) Lookup(address netip.Addr) (string, bool) { + return s.storage.FakeIPLoad(address) +} + +func (s *Store) Reset() error { + return s.storage.FakeIPReset() +} diff --git a/dns/transport/hosts/hosts.go b/dns/transport/hosts/hosts.go new file mode 100644 index 00000000..f0e70a9a --- /dev/null +++ b/dns/transport/hosts/hosts.go @@ -0,0 +1,87 @@ +package hosts + +import ( + "context" + "net/netip" + "os" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/service/filemanager" + + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.HostsDNSServerOptions](registry, C.DNSTypeHosts, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + files []*File + predefined map[string][]netip.Addr +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.HostsDNSServerOptions) (adapter.DNSTransport, error) { + var ( + files []*File + predefined = make(map[string][]netip.Addr) + ) + if len(options.Path) == 0 { + files = append(files, NewFile(DefaultPath)) + } else { + for _, path := range options.Path { + files = append(files, NewFile(filemanager.BasePath(ctx, os.ExpandEnv(path)))) + } + } + if options.Predefined != nil { + for _, entry := range options.Predefined.Entries() { + predefined[mDNS.CanonicalName(entry.Key)] = entry.Value + } + } + return &Transport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHosts, tag, nil), + files: files, + predefined: predefined, + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + return nil +} + +func (t *Transport) Close() error { + return nil +} + +func (t *Transport) Reset() { +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + domain := mDNS.CanonicalName(question.Name) + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { + if addresses, ok := t.predefined[domain]; ok { + return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil + } + for _, file := range t.files { + addresses := file.Lookup(domain) + if len(addresses) > 0 { + return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil + } + } + } + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeNameError, + Response: true, + }, + Question: []mDNS.Question{question}, + }, nil +} diff --git a/dns/transport/hosts/hosts_file.go b/dns/transport/hosts/hosts_file.go new file mode 100644 index 00000000..ec384882 --- /dev/null +++ b/dns/transport/hosts/hosts_file.go @@ -0,0 +1,102 @@ +package hosts + +import ( + "bufio" + "errors" + "io" + "net/netip" + "os" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +const cacheMaxAge = 5 * time.Second + +type File struct { + path string + access sync.Mutex + byName map[string][]netip.Addr + expire time.Time + modTime time.Time + size int64 +} + +func NewFile(path string) *File { + return &File{ + path: path, + } +} + +func (f *File) Lookup(name string) []netip.Addr { + f.access.Lock() + defer f.access.Unlock() + f.update() + return f.byName[dns.CanonicalName(name)] +} + +func (f *File) update() { + now := time.Now() + if now.Before(f.expire) && len(f.byName) > 0 { + return + } + stat, err := os.Stat(f.path) + if err != nil { + return + } + if f.modTime.Equal(stat.ModTime()) && f.size == stat.Size() { + f.expire = now.Add(cacheMaxAge) + return + } + byName := make(map[string][]netip.Addr) + file, err := os.Open(f.path) + if err != nil { + return + } + defer file.Close() + reader := bufio.NewReader(file) + var ( + prefix []byte + line []byte + isPrefix bool + ) + for { + line, isPrefix, err = reader.ReadLine() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return + } + if isPrefix { + prefix = append(prefix, line...) + continue + } else if len(prefix) > 0 { + line = append(prefix, line...) + prefix = nil + } + commentIndex := strings.IndexRune(string(line), '#') + if commentIndex != -1 { + line = line[:commentIndex] + } + fields := strings.Fields(string(line)) + if len(fields) < 2 { + continue + } + var addr netip.Addr + addr, err = netip.ParseAddr(fields[0]) + if err != nil { + continue + } + for index := 1; index < len(fields); index++ { + canonicalName := dns.CanonicalName(fields[index]) + byName[canonicalName] = append(byName[canonicalName], addr) + } + } + f.expire = now.Add(cacheMaxAge) + f.modTime = stat.ModTime() + f.size = stat.Size() + f.byName = byName +} diff --git a/dns/transport/hosts/hosts_test.go b/dns/transport/hosts/hosts_test.go new file mode 100644 index 00000000..3ae160b7 --- /dev/null +++ b/dns/transport/hosts/hosts_test.go @@ -0,0 +1,16 @@ +package hosts_test + +import ( + "net/netip" + "testing" + + "github.com/sagernet/sing-box/dns/transport/hosts" + + "github.com/stretchr/testify/require" +) + +func TestHosts(t *testing.T) { + t.Parallel() + require.Equal(t, []netip.Addr{netip.AddrFrom4([4]byte{127, 0, 0, 1}), netip.IPv6Loopback()}, hosts.NewFile("testdata/hosts").Lookup("localhost")) + require.NotEmpty(t, hosts.NewFile(hosts.DefaultPath).Lookup("localhost")) +} diff --git a/dns/transport/hosts/hosts_unix.go b/dns/transport/hosts/hosts_unix.go new file mode 100644 index 00000000..4caed8b4 --- /dev/null +++ b/dns/transport/hosts/hosts_unix.go @@ -0,0 +1,5 @@ +//go:build !windows + +package hosts + +var DefaultPath = "/etc/hosts" diff --git a/dns/transport/hosts/hosts_windows.go b/dns/transport/hosts/hosts_windows.go new file mode 100644 index 00000000..3144e50d --- /dev/null +++ b/dns/transport/hosts/hosts_windows.go @@ -0,0 +1,17 @@ +package hosts + +import ( + "path/filepath" + + "golang.org/x/sys/windows" +) + +var DefaultPath string + +func init() { + systemDirectory, err := windows.GetSystemDirectory() + if err != nil { + systemDirectory = "C:\\Windows\\System32" + } + DefaultPath = filepath.Join(systemDirectory, "Drivers/etc/hosts") +} diff --git a/dns/transport/hosts/testdata/hosts b/dns/transport/hosts/testdata/hosts new file mode 100644 index 00000000..9ddcc8c1 --- /dev/null +++ b/dns/transport/hosts/testdata/hosts @@ -0,0 +1,2 @@ +127.0.0.1 localhost +::1 localhost diff --git a/dns/transport/https.go b/dns/transport/https.go new file mode 100644 index 00000000..b508e6ea --- /dev/null +++ b/dns/transport/https.go @@ -0,0 +1,224 @@ +package transport + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "strconv" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + sHTTP "github.com/sagernet/sing/protocol/http" + + mDNS "github.com/miekg/dns" + "golang.org/x/net/http2" +) + +const MimeType = "application/dns-message" + +var _ adapter.DNSTransport = (*HTTPSTransport)(nil) + +func RegisterHTTPS(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteHTTPSDNSServerOptions](registry, C.DNSTypeHTTPS, NewHTTPS) +} + +type HTTPSTransport struct { + dns.TransportAdapter + logger logger.ContextLogger + dialer N.Dialer + destination *url.URL + headers http.Header + transportAccess sync.Mutex + transport *HTTPSTransportWrapper + transportResetAt time.Time +} + +func NewHTTPS(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteHTTPSDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions) + if err != nil { + return nil, err + } + tlsOptions := common.PtrValueOrDefault(options.TLS) + tlsOptions.Enabled = true + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, tlsOptions) + if err != nil { + return nil, err + } + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"}) + } + headers := options.Headers.Build() + host := headers.Get("Host") + if host != "" { + headers.Del("Host") + } else { + if tlsConfig.ServerName() != "" { + host = tlsConfig.ServerName() + } else { + host = options.Server + } + } + destinationURL := url.URL{ + Scheme: "https", + Host: host, + } + if destinationURL.Host == "" { + destinationURL.Host = options.Server + } + if options.ServerPort != 0 && options.ServerPort != 443 { + destinationURL.Host = net.JoinHostPort(destinationURL.Host, strconv.Itoa(int(options.ServerPort))) + } + path := options.Path + if path == "" { + path = "/dns-query" + } + err = sHTTP.URLSetPath(&destinationURL, path) + if err != nil { + return nil, err + } + serverAddr := options.DNSServerAddressOptions.Build() + if serverAddr.Port == 0 { + serverAddr.Port = 443 + } + if !serverAddr.IsValid() { + return nil, E.New("invalid server address: ", serverAddr) + } + return NewHTTPSRaw( + dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeHTTPS, tag, options.RemoteDNSServerOptions), + logger, + transportDialer, + &destinationURL, + headers, + serverAddr, + tlsConfig, + ), nil +} + +func NewHTTPSRaw( + adapter dns.TransportAdapter, + logger log.ContextLogger, + dialer N.Dialer, + destination *url.URL, + headers http.Header, + serverAddr M.Socksaddr, + tlsConfig tls.Config, +) *HTTPSTransport { + return &HTTPSTransport{ + TransportAdapter: adapter, + logger: logger, + dialer: dialer, + destination: destination, + headers: headers, + transport: NewHTTPSTransportWrapper(tls.NewDialer(dialer, tlsConfig), serverAddr), + } +} + +func (t *HTTPSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return dialer.InitializeDetour(t.dialer) +} + +func (t *HTTPSTransport) Close() error { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + t.transport.CloseIdleConnections() + t.transport = t.transport.Clone() + return nil +} + +func (t *HTTPSTransport) Reset() { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + t.transport.CloseIdleConnections() + t.transport = t.transport.Clone() +} + +func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + startAt := time.Now() + response, err := t.exchange(ctx, message) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + if t.transportResetAt.After(startAt) { + return nil, err + } + t.transport.CloseIdleConnections() + t.transport = t.transport.Clone() + t.transportResetAt = time.Now() + } + return nil, err + } + return response, nil +} + +func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + exMessage := *message + exMessage.Id = 0 + exMessage.Compress = true + requestBuffer := buf.NewSize(1 + message.Len()) + rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes()) + if err != nil { + requestBuffer.Release() + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage)) + if err != nil { + requestBuffer.Release() + return nil, err + } + request.Header = t.headers.Clone() + request.Header.Set("Content-Type", MimeType) + request.Header.Set("Accept", MimeType) + t.transportAccess.Lock() + currentTransport := t.transport + t.transportAccess.Unlock() + response, err := currentTransport.RoundTrip(request) + requestBuffer.Release() + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, E.New("unexpected status: ", response.Status) + } + var responseMessage mDNS.Msg + if response.ContentLength > 0 { + responseBuffer := buf.NewSize(int(response.ContentLength)) + defer responseBuffer.Release() + _, err = responseBuffer.ReadFullFrom(response.Body, int(response.ContentLength)) + if err != nil { + return nil, err + } + err = responseMessage.Unpack(responseBuffer.Bytes()) + } else { + rawMessage, err = io.ReadAll(response.Body) + if err != nil { + return nil, err + } + err = responseMessage.Unpack(rawMessage) + } + if err != nil { + return nil, err + } + return &responseMessage, nil +} diff --git a/dns/transport/https_transport.go b/dns/transport/https_transport.go new file mode 100644 index 00000000..84cfa17c --- /dev/null +++ b/dns/transport/https_transport.go @@ -0,0 +1,80 @@ +package transport + +import ( + "context" + "errors" + "net" + "net/http" + "sync/atomic" + + "github.com/sagernet/sing-box/common/tls" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + + "golang.org/x/net/http2" +) + +var errFallback = E.New("fallback to HTTP/1.1") + +type HTTPSTransportWrapper struct { + http2Transport *http2.Transport + httpTransport *http.Transport + fallback *atomic.Bool +} + +func NewHTTPSTransportWrapper(dialer tls.Dialer, serverAddr M.Socksaddr) *HTTPSTransportWrapper { + var fallback atomic.Bool + return &HTTPSTransportWrapper{ + http2Transport: &http2.Transport{ + DialTLSContext: func(ctx context.Context, _, _ string, _ *tls.STDConfig) (net.Conn, error) { + tlsConn, err := dialer.DialTLSContext(ctx, serverAddr) + if err != nil { + return nil, err + } + state := tlsConn.ConnectionState() + if state.NegotiatedProtocol == http2.NextProtoTLS { + return tlsConn, nil + } + tlsConn.Close() + fallback.Store(true) + return nil, errFallback + }, + }, + httpTransport: &http.Transport{ + DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialTLSContext(ctx, serverAddr) + }, + }, + fallback: &fallback, + } +} + +func (h *HTTPSTransportWrapper) RoundTrip(request *http.Request) (*http.Response, error) { + if h.fallback.Load() { + return h.httpTransport.RoundTrip(request) + } else { + response, err := h.http2Transport.RoundTrip(request) + if err != nil { + if errors.Is(err, errFallback) { + return h.httpTransport.RoundTrip(request) + } + return nil, err + } + return response, nil + } +} + +func (h *HTTPSTransportWrapper) CloseIdleConnections() { + h.http2Transport.CloseIdleConnections() + h.httpTransport.CloseIdleConnections() +} + +func (h *HTTPSTransportWrapper) Clone() *HTTPSTransportWrapper { + return &HTTPSTransportWrapper{ + httpTransport: h.httpTransport, + http2Transport: &http2.Transport{ + DialTLSContext: h.http2Transport.DialTLSContext, + }, + fallback: h.fallback, + } +} diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go new file mode 100644 index 00000000..a3909acc --- /dev/null +++ b/dns/transport/local/local.go @@ -0,0 +1,94 @@ +//go:build !darwin + +package local + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.LocalDNSServerOptions](registry, C.DNSTypeLocal, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + logger logger.ContextLogger + hosts *hosts.File + dialer N.Dialer + preferGo bool + resolved ResolvedResolver +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewLocalDialer(ctx, options) + if err != nil { + return nil, err + } + return &Transport{ + TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeLocal, tag, options), + ctx: ctx, + logger: logger, + hosts: hosts.NewFile(hosts.DefaultPath), + dialer: transportDialer, + preferGo: options.PreferGo, + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateInitialize: + if !t.preferGo { + if isSystemdResolvedManaged() { + resolvedResolver, err := NewResolvedResolver(t.ctx, t.logger) + if err == nil { + err = resolvedResolver.Start() + if err == nil { + t.resolved = resolvedResolver + } else { + t.logger.Warn(E.Cause(err, "initialize resolved resolver")) + } + } + } + } + } + return nil +} + +func (t *Transport) Close() error { + if t.resolved != nil { + return t.resolved.Close() + } + return nil +} + +func (t *Transport) Reset() { +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if t.resolved != nil { + return t.resolved.Exchange(ctx, message) + } + question := message.Question[0] + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { + addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) + if len(addresses) > 0 { + return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil + } + } + return t.exchange(ctx, message, question.Name) +} diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go new file mode 100644 index 00000000..eb33d64f --- /dev/null +++ b/dns/transport/local/local_darwin.go @@ -0,0 +1,92 @@ +//go:build darwin + +package local + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.LocalDNSServerOptions](registry, C.DNSTypeLocal, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + logger logger.ContextLogger + hosts *hosts.File + dialer N.Dialer + fallback bool + dhcpTransport dhcpTransport +} + +type dhcpTransport interface { + adapter.DNSTransport + Fetch() []M.Socksaddr + Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewLocalDialer(ctx, options) + if err != nil { + return nil, err + } + return &Transport{ + TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeLocal, tag, options), + ctx: ctx, + logger: logger, + hosts: hosts.NewFile(hosts.DefaultPath), + dialer: transportDialer, + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + inboundManager := service.FromContext[adapter.InboundManager](t.ctx) + for _, inbound := range inboundManager.Inbounds() { + if inbound.Type() == C.TypeTun { + t.fallback = true + break + } + } + if t.fallback { + t.dhcpTransport = newDHCPTransport(t.TransportAdapter, log.ContextWithOverrideLevel(t.ctx, log.LevelDebug), t.dialer, t.logger) + if t.dhcpTransport != nil { + err := t.dhcpTransport.Start(stage) + if err != nil { + return err + } + } + } + return nil +} + +func (t *Transport) Close() error { + return common.Close( + t.dhcpTransport, + ) +} + +func (t *Transport) Reset() { + if t.dhcpTransport != nil { + t.dhcpTransport.Reset() + } +} diff --git a/dns/transport/local/local_darwin_cgo.go b/dns/transport/local/local_darwin_cgo.go new file mode 100644 index 00000000..318c38f3 --- /dev/null +++ b/dns/transport/local/local_darwin_cgo.go @@ -0,0 +1,249 @@ +//go:build darwin + +package local + +/* +#include +#include +#include + +static void *cgo_dns_open_super() { + return (void *)dns_open(NULL); +} + +static void cgo_dns_close(void *opaque) { + if (opaque != NULL) dns_free((dns_handle_t)opaque); +} + +static int cgo_dns_search(void *opaque, const char *name, int class, int type, + unsigned char *answer, int anslen) { + dns_handle_t handle = (dns_handle_t)opaque; + struct sockaddr_storage from; + uint32_t fromlen = sizeof(from); + return dns_search(handle, name, class, type, (char *)answer, anslen, (struct sockaddr *)&from, &fromlen); +} + +static void *cgo_res_init() { + res_state state = calloc(1, sizeof(struct __res_state)); + if (state == NULL) return NULL; + if (res_ninit(state) != 0) { + free(state); + return NULL; + } + return state; +} + +static void cgo_res_destroy(void *opaque) { + res_state state = (res_state)opaque; + res_ndestroy(state); + free(state); +} + +static int cgo_res_nsearch(void *opaque, const char *dname, int class, int type, + unsigned char *answer, int anslen, + int timeout_seconds, + int *out_h_errno) { + res_state state = (res_state)opaque; + state->retrans = timeout_seconds; + state->retry = 1; + int n = res_nsearch(state, dname, class, type, answer, anslen); + if (n < 0) { + *out_h_errno = state->res_h_errno; + } + return n; +} +*/ +import "C" + +import ( + "context" + "errors" + "time" + "unsafe" + + boxC "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +const ( + darwinResolverHostNotFound = 1 + darwinResolverTryAgain = 2 + darwinResolverNoRecovery = 3 + darwinResolverNoData = 4 + + darwinResolverMaxPacketSize = 65535 +) + +var errDarwinNeedLargerBuffer = errors.New("darwin resolver response truncated") + +func darwinLookupSystemDNS(name string, class, qtype, timeoutSeconds int) (*mDNS.Msg, error) { + response, err := darwinSearchWithSystemRouting(name, class, qtype) + if err == nil { + return response, nil + } + fallbackResponse, fallbackErr := darwinSearchWithResolv(name, class, qtype, timeoutSeconds) + if fallbackErr == nil || fallbackResponse != nil { + return fallbackResponse, fallbackErr + } + return nil, E.Errors( + E.Cause(err, "dns_search"), + E.Cause(fallbackErr, "res_nsearch"), + ) +} + +func darwinSearchWithSystemRouting(name string, class, qtype int) (*mDNS.Msg, error) { + handle := C.cgo_dns_open_super() + if handle == nil { + return nil, E.New("dns_open failed") + } + defer C.cgo_dns_close(handle) + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + bufSize := 1232 + for { + answer := make([]byte, bufSize) + n := C.cgo_dns_search(handle, cName, C.int(class), C.int(qtype), + (*C.uchar)(unsafe.Pointer(&answer[0])), C.int(len(answer))) + if n <= 0 { + return nil, E.New("dns_search failed for ", name) + } + if int(n) > bufSize { + bufSize = int(n) + continue + } + return unpackDarwinResolverMessage(answer[:int(n)], "dns_search") + } +} + +func darwinSearchWithResolv(name string, class, qtype int, timeoutSeconds int) (*mDNS.Msg, error) { + state := C.cgo_res_init() + if state == nil { + return nil, E.New("res_ninit failed") + } + defer C.cgo_res_destroy(state) + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + bufSize := 1232 + for { + answer := make([]byte, bufSize) + var hErrno C.int + n := C.cgo_res_nsearch(state, cName, C.int(class), C.int(qtype), + (*C.uchar)(unsafe.Pointer(&answer[0])), C.int(len(answer)), + C.int(timeoutSeconds), + &hErrno) + if n >= 0 { + if int(n) > bufSize { + bufSize = int(n) + continue + } + return unpackDarwinResolverMessage(answer[:int(n)], "res_nsearch") + } + response, err := handleDarwinResolvFailure(name, answer, int(hErrno)) + if err == nil { + return response, nil + } + if errors.Is(err, errDarwinNeedLargerBuffer) && bufSize < darwinResolverMaxPacketSize { + bufSize *= 2 + if bufSize > darwinResolverMaxPacketSize { + bufSize = darwinResolverMaxPacketSize + } + continue + } + return nil, err + } +} + +func unpackDarwinResolverMessage(packet []byte, source string) (*mDNS.Msg, error) { + var response mDNS.Msg + err := response.Unpack(packet) + if err != nil { + return nil, E.Cause(err, "unpack ", source, " response") + } + return &response, nil +} + +func handleDarwinResolvFailure(name string, answer []byte, hErrno int) (*mDNS.Msg, error) { + response, err := unpackDarwinResolverMessage(answer, "res_nsearch failure") + if err == nil && response.Response { + if response.Truncated && len(answer) < darwinResolverMaxPacketSize { + return nil, errDarwinNeedLargerBuffer + } + return response, nil + } + return nil, darwinResolverHErrno(name, hErrno) +} + +func darwinResolverHErrno(name string, hErrno int) error { + switch hErrno { + case darwinResolverHostNotFound: + return dns.RcodeNameError + case darwinResolverTryAgain: + return dns.RcodeServerFailure + case darwinResolverNoRecovery: + return dns.RcodeServerFailure + case darwinResolverNoData: + return dns.RcodeSuccess + default: + return E.New("res_nsearch: unknown error ", hErrno, " for ", name) + } +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { + addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) + if len(addresses) > 0 { + return dns.FixedResponse(message.Id, question, addresses, boxC.DefaultDNSTTL), nil + } + } + if t.fallback && t.dhcpTransport != nil { + dhcpServers := t.dhcpTransport.Fetch() + if len(dhcpServers) > 0 { + return t.dhcpTransport.Exchange0(ctx, message, dhcpServers) + } + } + name := question.Name + timeoutSeconds := int(boxC.DNSTimeout / time.Second) + if deadline, hasDeadline := ctx.Deadline(); hasDeadline { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, context.DeadlineExceeded + } + seconds := int(remaining.Seconds()) + if seconds < 1 { + seconds = 1 + } + timeoutSeconds = seconds + } + type resolvResult struct { + response *mDNS.Msg + err error + } + resultCh := make(chan resolvResult, 1) + go func() { + response, err := darwinLookupSystemDNS(name, int(question.Qclass), int(question.Qtype), timeoutSeconds) + resultCh <- resolvResult{response, err} + }() + var result resolvResult + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result = <-resultCh: + } + if result.err != nil { + var rcodeError dns.RcodeError + if errors.As(result.err, &rcodeError) { + return dns.FixedResponseStatus(message, int(rcodeError)), nil + } + return nil, result.err + } + result.response.Id = message.Id + return result.response, nil +} diff --git a/dns/transport/local/local_darwin_dhcp.go b/dns/transport/local/local_darwin_dhcp.go new file mode 100644 index 00000000..b228b76a --- /dev/null +++ b/dns/transport/local/local_darwin_dhcp.go @@ -0,0 +1,16 @@ +//go:build darwin && with_dhcp + +package local + +import ( + "context" + + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport/dhcp" + "github.com/sagernet/sing-box/log" + N "github.com/sagernet/sing/common/network" +) + +func newDHCPTransport(transportAdapter dns.TransportAdapter, ctx context.Context, dialer N.Dialer, logger log.ContextLogger) dhcpTransport { + return dhcp.NewRawTransport(transportAdapter, ctx, dialer, logger) +} diff --git a/dns/transport/local/local_darwin_nodhcp.go b/dns/transport/local/local_darwin_nodhcp.go new file mode 100644 index 00000000..5ce84690 --- /dev/null +++ b/dns/transport/local/local_darwin_nodhcp.go @@ -0,0 +1,15 @@ +//go:build darwin && !with_dhcp + +package local + +import ( + "context" + + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + N "github.com/sagernet/sing/common/network" +) + +func newDHCPTransport(transportAdapter dns.TransportAdapter, ctx context.Context, dialer N.Dialer, logger log.ContextLogger) dhcpTransport { + return nil +} diff --git a/dns/transport/local/local_resolved.go b/dns/transport/local/local_resolved.go new file mode 100644 index 00000000..e0128d6d --- /dev/null +++ b/dns/transport/local/local_resolved.go @@ -0,0 +1,13 @@ +package local + +import ( + "context" + + mDNS "github.com/miekg/dns" +) + +type ResolvedResolver interface { + Start() error + Close() error + Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) +} diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go new file mode 100644 index 00000000..fc3ca2b7 --- /dev/null +++ b/dns/transport/local/local_resolved_linux.go @@ -0,0 +1,501 @@ +package local + +import ( + "bufio" + "context" + "errors" + "net/netip" + "os" + "strings" + "sync" + "sync/atomic" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + dnsTransport "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/resolved" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + "github.com/godbus/dbus/v5" + mDNS "github.com/miekg/dns" +) + +func isSystemdResolvedManaged() bool { + resolvContent, err := os.Open("/etc/resolv.conf") + if err != nil { + return false + } + defer resolvContent.Close() + scanner := bufio.NewScanner(resolvContent) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || line[0] != '#' { + return false + } + if strings.Contains(line, "systemd-resolved") { + return true + } + } + return false +} + +type DBusResolvedResolver struct { + ctx context.Context + logger logger.ContextLogger + interfaceMonitor tun.DefaultInterfaceMonitor + interfaceCallback *list.Element[tun.DefaultInterfaceUpdateCallback] + systemBus *dbus.Conn + savedServerSet atomic.Pointer[resolvedServerSet] + closeOnce sync.Once +} + +type resolvedServerSet struct { + servers []resolvedServer +} + +type resolvedServer struct { + primaryTransport adapter.DNSTransport + fallbackTransport adapter.DNSTransport +} + +type resolvedServerSpecification struct { + address netip.Addr + port uint16 + serverName string +} + +func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (ResolvedResolver, error) { + interfaceMonitor := service.FromContext[adapter.NetworkManager](ctx).InterfaceMonitor() + if interfaceMonitor == nil { + return nil, os.ErrInvalid + } + systemBus, err := dbus.SystemBus() + if err != nil { + return nil, err + } + return &DBusResolvedResolver{ + ctx: ctx, + logger: logger, + interfaceMonitor: interfaceMonitor, + systemBus: systemBus, + }, nil +} + +func (t *DBusResolvedResolver) Start() error { + t.updateStatus() + t.interfaceCallback = t.interfaceMonitor.RegisterCallback(t.updateDefaultInterface) + err := t.systemBus.BusObject().AddMatchSignal( + "org.freedesktop.DBus", + "NameOwnerChanged", + dbus.WithMatchSender("org.freedesktop.DBus"), + dbus.WithMatchArg(0, "org.freedesktop.resolve1"), + ).Err + if err != nil { + return E.Cause(err, "configure resolved restart listener") + } + err = t.systemBus.BusObject().AddMatchSignal( + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + dbus.WithMatchSender("org.freedesktop.resolve1"), + dbus.WithMatchArg(0, "org.freedesktop.resolve1.Manager"), + ).Err + if err != nil { + return E.Cause(err, "configure resolved properties listener") + } + go t.loopUpdateStatus() + return nil +} + +func (t *DBusResolvedResolver) Close() error { + var closeErr error + t.closeOnce.Do(func() { + serverSet := t.savedServerSet.Swap(nil) + if serverSet != nil { + closeErr = serverSet.Close() + } + if t.interfaceCallback != nil { + t.interfaceMonitor.UnregisterCallback(t.interfaceCallback) + } + if t.systemBus != nil { + _ = t.systemBus.Close() + } + }) + return closeErr +} + +func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + serverSet := t.savedServerSet.Load() + if serverSet == nil { + var err error + serverSet, err = t.checkResolved(context.Background()) + if err != nil { + return nil, err + } + previousServerSet := t.savedServerSet.Swap(serverSet) + if previousServerSet != nil { + _ = previousServerSet.Close() + } + } + response, err := t.exchangeServerSet(ctx, message, serverSet) + if err == nil { + return response, nil + } + t.updateStatus() + refreshedServerSet := t.savedServerSet.Load() + if refreshedServerSet == nil || refreshedServerSet == serverSet { + return nil, err + } + return t.exchangeServerSet(ctx, message, refreshedServerSet) +} + +func (t *DBusResolvedResolver) loopUpdateStatus() { + signalChan := make(chan *dbus.Signal, 1) + t.systemBus.Signal(signalChan) + for signal := range signalChan { + switch signal.Name { + case "org.freedesktop.DBus.NameOwnerChanged": + if len(signal.Body) != 3 { + continue + } + newOwner, loaded := signal.Body[2].(string) + if !loaded || newOwner == "" { + continue + } + t.updateStatus() + case "org.freedesktop.DBus.Properties.PropertiesChanged": + if !shouldUpdateResolvedServerSet(signal) { + continue + } + t.updateStatus() + } + } +} + +func (t *DBusResolvedResolver) updateStatus() { + serverSet, err := t.checkResolved(context.Background()) + oldServerSet := t.savedServerSet.Swap(serverSet) + if oldServerSet != nil { + _ = oldServerSet.Close() + } + if err != nil { + var dbusErr dbus.Error + if !errors.As(err, &dbusErr) || dbusErr.Name != "org.freedesktop.DBus.Error.NameHasNoOwner" { + t.logger.Debug(E.Cause(err, "systemd-resolved service unavailable")) + } + if oldServerSet != nil { + t.logger.Debug("systemd-resolved service is gone") + } + return + } else if oldServerSet == nil { + t.logger.Debug("using systemd-resolved service as resolver") + } +} + +func (t *DBusResolvedResolver) exchangeServerSet(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet) (*mDNS.Msg, error) { + if serverSet == nil || len(serverSet.servers) == 0 { + return nil, E.New("link has no DNS servers configured") + } + var lastError error + for _, server := range serverSet.servers { + response, err := server.primaryTransport.Exchange(ctx, message) + if err != nil && server.fallbackTransport != nil { + response, err = server.fallbackTransport.Exchange(ctx, message) + } + if err != nil { + lastError = err + continue + } + return response, nil + } + return nil, lastError +} + +func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServerSet, error) { + dbusObject := t.systemBus.Object("org.freedesktop.resolve1", "/org/freedesktop/resolve1") + err := dbusObject.Call("org.freedesktop.DBus.Peer.Ping", 0).Err + if err != nil { + return nil, err + } + defaultInterface := t.interfaceMonitor.DefaultInterface() + if defaultInterface == nil { + return nil, E.New("missing default interface") + } + call := dbusObject.(*dbus.Object).CallWithContext( + ctx, + "org.freedesktop.resolve1.Manager.GetLink", + 0, + int32(defaultInterface.Index), + ) + if call.Err != nil { + return nil, call.Err + } + var linkPath dbus.ObjectPath + err = call.Store(&linkPath) + if err != nil { + return nil, err + } + linkObject := t.systemBus.Object("org.freedesktop.resolve1", linkPath) + if linkObject == nil { + return nil, E.New("missing link object for default interface") + } + dnsOverTLSMode, err := loadResolvedLinkDNSOverTLS(linkObject) + if err != nil { + return nil, err + } + linkDNSEx, err := loadResolvedLinkDNSEx(linkObject) + if err != nil { + return nil, err + } + linkDNS, err := loadResolvedLinkDNS(linkObject) + if err != nil { + return nil, err + } + if len(linkDNSEx) == 0 && len(linkDNS) == 0 { + for _, inbound := range service.FromContext[adapter.InboundManager](t.ctx).Inbounds() { + if inbound.Type() == C.TypeTun { + return nil, E.New("No appropriate name servers or networks for name found") + } + } + return nil, E.New("link has no DNS servers configured") + } + serverDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ + BindInterface: defaultInterface.Name, + UDPFragmentDefault: true, + }) + if err != nil { + return nil, err + } + var serverSpecifications []resolvedServerSpecification + if len(linkDNSEx) > 0 { + for _, entry := range linkDNSEx { + serverSpecification, loaded := buildResolvedServerSpecification(defaultInterface.Name, entry.Address, entry.Port, entry.Name) + if !loaded { + continue + } + serverSpecifications = append(serverSpecifications, serverSpecification) + } + } else { + for _, entry := range linkDNS { + serverSpecification, loaded := buildResolvedServerSpecification(defaultInterface.Name, entry.Address, 0, "") + if !loaded { + continue + } + serverSpecifications = append(serverSpecifications, serverSpecification) + } + } + if len(serverSpecifications) == 0 { + return nil, E.New("no valid DNS servers on link") + } + serverSet := &resolvedServerSet{ + servers: make([]resolvedServer, 0, len(serverSpecifications)), + } + for _, serverSpecification := range serverSpecifications { + server, createErr := t.createResolvedServer(serverDialer, dnsOverTLSMode, serverSpecification) + if createErr != nil { + _ = serverSet.Close() + return nil, createErr + } + serverSet.servers = append(serverSet.servers, server) + } + return serverSet, nil +} + +func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOverTLSMode string, serverSpecification resolvedServerSpecification) (resolvedServer, error) { + if dnsOverTLSMode == "yes" { + primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, true) + if err != nil { + return resolvedServer{}, err + } + return resolvedServer{ + primaryTransport: primaryTransport, + }, nil + } + if dnsOverTLSMode == "opportunistic" { + primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, true) + if err != nil { + return resolvedServer{}, err + } + fallbackTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, false) + if err != nil { + _ = primaryTransport.Close() + return resolvedServer{}, err + } + return resolvedServer{ + primaryTransport: primaryTransport, + fallbackTransport: fallbackTransport, + }, nil + } + primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, false) + if err != nil { + return resolvedServer{}, err + } + return resolvedServer{ + primaryTransport: primaryTransport, + }, nil +} + +func (t *DBusResolvedResolver) createResolvedTransport(serverDialer N.Dialer, serverSpecification resolvedServerSpecification, useTLS bool) (adapter.DNSTransport, error) { + serverAddress := M.SocksaddrFrom(serverSpecification.address, resolvedServerPort(serverSpecification.port, useTLS)) + if useTLS { + tlsAddress := serverSpecification.address + if tlsAddress.Zone() != "" { + tlsAddress = tlsAddress.WithZone("") + } + serverName := serverSpecification.serverName + if serverName == "" { + serverName = tlsAddress.String() + } + tlsConfig, err := tls.NewClient(t.ctx, t.logger, tlsAddress.String(), option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + }) + if err != nil { + return nil, err + } + serverTransport := dnsTransport.NewTLSRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeTLS, "", nil), serverDialer, serverAddress, tlsConfig) + err = serverTransport.Start(adapter.StartStateStart) + if err != nil { + _ = serverTransport.Close() + return nil, err + } + return serverTransport, nil + } + serverTransport := dnsTransport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), serverDialer, serverAddress) + err := serverTransport.Start(adapter.StartStateStart) + if err != nil { + _ = serverTransport.Close() + return nil, err + } + return serverTransport, nil +} + +func (s *resolvedServerSet) Close() error { + var errors []error + for _, server := range s.servers { + errors = append(errors, server.primaryTransport.Close()) + if server.fallbackTransport != nil { + errors = append(errors, server.fallbackTransport.Close()) + } + } + return E.Errors(errors...) +} + +func buildResolvedServerSpecification(interfaceName string, rawAddress []byte, port uint16, serverName string) (resolvedServerSpecification, bool) { + address, loaded := netip.AddrFromSlice(rawAddress) + if !loaded { + return resolvedServerSpecification{}, false + } + if address.Is6() && address.IsLinkLocalUnicast() && address.Zone() == "" { + address = address.WithZone(interfaceName) + } + return resolvedServerSpecification{ + address: address, + port: port, + serverName: serverName, + }, true +} + +func resolvedServerPort(port uint16, useTLS bool) uint16 { + if port > 0 { + return port + } + if useTLS { + return 853 + } + return 53 +} + +func loadResolvedLinkDNS(linkObject dbus.BusObject) ([]resolved.LinkDNS, error) { + dnsProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNS") + if err != nil { + if isResolvedUnknownPropertyError(err) { + return nil, nil + } + return nil, err + } + var linkDNS []resolved.LinkDNS + err = dnsProperty.Store(&linkDNS) + if err != nil { + return nil, err + } + return linkDNS, nil +} + +func loadResolvedLinkDNSEx(linkObject dbus.BusObject) ([]resolved.LinkDNSEx, error) { + dnsProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNSEx") + if err != nil { + if isResolvedUnknownPropertyError(err) { + return nil, nil + } + return nil, err + } + var linkDNSEx []resolved.LinkDNSEx + err = dnsProperty.Store(&linkDNSEx) + if err != nil { + return nil, err + } + return linkDNSEx, nil +} + +func loadResolvedLinkDNSOverTLS(linkObject dbus.BusObject) (string, error) { + dnsOverTLSProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNSOverTLS") + if err != nil { + if isResolvedUnknownPropertyError(err) { + return "", nil + } + return "", err + } + var dnsOverTLSMode string + err = dnsOverTLSProperty.Store(&dnsOverTLSMode) + if err != nil { + return "", err + } + return dnsOverTLSMode, nil +} + +func isResolvedUnknownPropertyError(err error) bool { + var dbusError dbus.Error + return errors.As(err, &dbusError) && dbusError.Name == "org.freedesktop.DBus.Error.UnknownProperty" +} + +func shouldUpdateResolvedServerSet(signal *dbus.Signal) bool { + if len(signal.Body) != 3 { + return true + } + changedProperties, loaded := signal.Body[1].(map[string]dbus.Variant) + if !loaded { + return true + } + for propertyName := range changedProperties { + switch propertyName { + case "DNS", "DNSEx", "DNSOverTLS": + return true + } + } + invalidatedProperties, loaded := signal.Body[2].([]string) + if !loaded { + return true + } + for _, propertyName := range invalidatedProperties { + switch propertyName { + case "DNS", "DNSEx", "DNSOverTLS": + return true + } + } + return false +} + +func (t *DBusResolvedResolver) updateDefaultInterface(defaultInterface *control.Interface, flags int) { + t.updateStatus() +} diff --git a/dns/transport/local/local_resolved_stub.go b/dns/transport/local/local_resolved_stub.go new file mode 100644 index 00000000..2e011851 --- /dev/null +++ b/dns/transport/local/local_resolved_stub.go @@ -0,0 +1,18 @@ +//go:build !linux + +package local + +import ( + "context" + "os" + + "github.com/sagernet/sing/common/logger" +) + +func isSystemdResolvedManaged() bool { + return false +} + +func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (ResolvedResolver, error) { + return nil, os.ErrInvalid +} diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go new file mode 100644 index 00000000..64a23a9f --- /dev/null +++ b/dns/transport/local/local_shared.go @@ -0,0 +1,185 @@ +//go:build !darwin + +package local + +import ( + "context" + "errors" + "math/rand" + "syscall" + "time" + + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { + systemConfig := getSystemDNSConfig(t.ctx) + if systemConfig.singleRequest || !(message.Question[0].Qtype == mDNS.TypeA || message.Question[0].Qtype == mDNS.TypeAAAA) { + return t.exchangeSingleRequest(ctx, systemConfig, message, domain) + } else { + return t.exchangeParallel(ctx, systemConfig, message, domain) + } +} + +func (t *Transport) exchangeSingleRequest(ctx context.Context, systemConfig *dnsConfig, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { + var lastErr error + for _, fqdn := range systemConfig.nameList(domain) { + response, err := t.tryOneName(ctx, systemConfig, fqdn, message) + if err != nil { + lastErr = err + continue + } + return response, nil + } + return nil, lastErr +} + +func (t *Transport) exchangeParallel(ctx context.Context, systemConfig *dnsConfig, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { + returned := make(chan struct{}) + defer close(returned) + type queryResult struct { + response *mDNS.Msg + err error + } + results := make(chan queryResult) + startRacer := func(ctx context.Context, fqdn string) { + response, err := t.tryOneName(ctx, systemConfig, fqdn, message) + select { + case results <- queryResult{response, err}: + case <-returned: + } + } + queryCtx, queryCancel := context.WithCancel(ctx) + defer queryCancel() + var nameCount int + for _, fqdn := range systemConfig.nameList(domain) { + nameCount++ + go startRacer(queryCtx, fqdn) + } + var errors []error + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-results: + if result.err == nil { + return result.response, nil + } + errors = append(errors, result.err) + if len(errors) == nameCount { + return nil, E.Errors(errors...) + } + } + } +} + +func (t *Transport) tryOneName(ctx context.Context, config *dnsConfig, fqdn string, message *mDNS.Msg) (*mDNS.Msg, error) { + serverOffset := config.serverOffset() + sLen := uint32(len(config.servers)) + var lastErr error + for i := 0; i < config.attempts; i++ { + for j := uint32(0); j < sLen; j++ { + server := config.servers[(serverOffset+j)%sLen] + question := message.Question[0] + question.Name = fqdn + response, err := t.exchangeOne(ctx, M.ParseSocksaddr(server), question, config.timeout, config.useTCP, config.trustAD) + if err != nil { + lastErr = err + continue + } + return response, nil + } + } + return nil, E.Cause(lastErr, fqdn) +} + +func (t *Transport) exchangeOne(ctx context.Context, server M.Socksaddr, question mDNS.Question, timeout time.Duration, useTCP, ad bool) (*mDNS.Msg, error) { + if server.Port == 0 { + server.Port = 53 + } + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: uint16(rand.Uint32()), + RecursionDesired: true, + AuthenticatedData: ad, + }, + Question: []mDNS.Question{question}, + Compress: true, + } + request.SetEdns0(buf.UDPBufferSize, false) + if !useTCP { + return t.exchangeUDP(ctx, server, request, timeout) + } else { + return t.exchangeTCP(ctx, server, request, timeout) + } +} + +func (t *Transport) exchangeUDP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg, timeout time.Duration) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, server) + if err != nil { + return nil, err + } + defer conn.Close() + if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { + newDeadline := time.Now().Add(timeout) + if deadline.After(newDeadline) { + deadline = newDeadline + } + conn.SetDeadline(deadline) + } + buffer := buf.Get(buf.UDPBufferSize) + defer buf.Put(buffer) + rawMessage, err := request.PackBuffer(buffer) + if err != nil { + return nil, E.Cause(err, "pack request") + } + _, err = conn.Write(rawMessage) + if err != nil { + if errors.Is(err, syscall.EMSGSIZE) { + return t.exchangeTCP(ctx, server, request, timeout) + } + return nil, E.Cause(err, "write request") + } + n, err := conn.Read(buffer) + if err != nil { + if errors.Is(err, syscall.EMSGSIZE) { + return t.exchangeTCP(ctx, server, request, timeout) + } + return nil, E.Cause(err, "read response") + } + var response mDNS.Msg + err = response.Unpack(buffer[:n]) + if err != nil { + return nil, E.Cause(err, "unpack response") + } + if response.Truncated { + return t.exchangeTCP(ctx, server, request, timeout) + } + return &response, nil +} + +func (t *Transport) exchangeTCP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg, timeout time.Duration) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, server) + if err != nil { + return nil, err + } + defer conn.Close() + if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { + newDeadline := time.Now().Add(timeout) + if deadline.After(newDeadline) { + deadline = newDeadline + } + conn.SetDeadline(deadline) + } + err = transport.WriteMessage(conn, 0, request) + if err != nil { + return nil, err + } + return transport.ReadMessage(conn) +} diff --git a/dns/transport/local/resolv.go b/dns/transport/local/resolv.go new file mode 100644 index 00000000..3586cbbf --- /dev/null +++ b/dns/transport/local/resolv.go @@ -0,0 +1,144 @@ +package local + +import ( + "context" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" +) + +type resolverConfig struct { + initOnce sync.Once + ch chan struct{} + lastChecked time.Time + dnsConfig atomic.Pointer[dnsConfig] +} + +var resolvConf resolverConfig + +func getSystemDNSConfig(ctx context.Context) *dnsConfig { + resolvConf.tryUpdate(ctx, "/etc/resolv.conf") + return resolvConf.dnsConfig.Load() +} + +func (conf *resolverConfig) init(ctx context.Context) { + conf.dnsConfig.Store(dnsReadConfig(ctx, "/etc/resolv.conf")) + conf.lastChecked = time.Now() + conf.ch = make(chan struct{}, 1) +} + +func (conf *resolverConfig) tryUpdate(ctx context.Context, name string) { + conf.initOnce.Do(func() { + conf.init(ctx) + }) + + if conf.dnsConfig.Load().noReload { + return + } + if !conf.tryAcquireSema() { + return + } + defer conf.releaseSema() + + now := time.Now() + if conf.lastChecked.After(now.Add(-5 * time.Second)) { + return + } + conf.lastChecked = now + if runtime.GOOS != "windows" { + var mtime time.Time + if fi, err := os.Stat(name); err == nil { + mtime = fi.ModTime() + } + if mtime.Equal(conf.dnsConfig.Load().mtime) { + return + } + } + dnsConf := dnsReadConfig(ctx, name) + conf.dnsConfig.Store(dnsConf) +} + +func (conf *resolverConfig) tryAcquireSema() bool { + select { + case conf.ch <- struct{}{}: + return true + default: + return false + } +} + +func (conf *resolverConfig) releaseSema() { + <-conf.ch +} + +type dnsConfig struct { + servers []string + search []string + ndots int + timeout time.Duration + attempts int + rotate bool + unknownOpt bool + lookup []string + err error + mtime time.Time + soffset uint32 + singleRequest bool + useTCP bool + trustAD bool + noReload bool +} + +func (c *dnsConfig) serverOffset() uint32 { + if c.rotate { + return atomic.AddUint32(&c.soffset, 1) - 1 // return 0 to start + } + return 0 +} + +func (c *dnsConfig) nameList(name string) []string { + l := len(name) + rooted := l > 0 && name[l-1] == '.' + if l > 254 || l == 254 && !rooted { + return nil + } + + if rooted { + if avoidDNS(name) { + return nil + } + return []string{name} + } + + hasNdots := strings.Count(name, ".") >= c.ndots + name += "." + // l++ + + names := make([]string, 0, 1+len(c.search)) + if hasNdots && !avoidDNS(name) { + names = append(names, name) + } + for _, suffix := range c.search { + fqdn := name + suffix + if !avoidDNS(fqdn) && len(fqdn) <= 254 { + names = append(names, fqdn) + } + } + if !hasNdots && !avoidDNS(name) { + names = append(names, name) + } + return names +} + +func avoidDNS(name string) bool { + if name == "" { + return true + } + if name[len(name)-1] == '.' { + name = name[:len(name)-1] + } + return strings.HasSuffix(name, ".onion") +} diff --git a/dns/transport/local/resolv_default.go b/dns/transport/local/resolv_default.go new file mode 100644 index 00000000..0a7d8810 --- /dev/null +++ b/dns/transport/local/resolv_default.go @@ -0,0 +1,23 @@ +package local + +import ( + "os" + "strings" + _ "unsafe" + + "github.com/miekg/dns" +) + +//go:linkname defaultNS net.defaultNS +var defaultNS []string + +func dnsDefaultSearch() []string { + hn, err := os.Hostname() + if err != nil { + return nil + } + if i := strings.IndexRune(hn, '.'); i >= 0 && i < len(hn)-1 { + return []string{dns.Fqdn(hn[i+1:])} + } + return nil +} diff --git a/dns/transport/local/resolv_test.go b/dns/transport/local/resolv_test.go new file mode 100644 index 00000000..546e8408 --- /dev/null +++ b/dns/transport/local/resolv_test.go @@ -0,0 +1,13 @@ +package local + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDNSReadConfig(t *testing.T) { + t.Parallel() + require.NoError(t, dnsReadConfig(context.Background(), "/etc/resolv.conf").err) +} diff --git a/dns/transport/local/resolv_unix.go b/dns/transport/local/resolv_unix.go new file mode 100644 index 00000000..51512f65 --- /dev/null +++ b/dns/transport/local/resolv_unix.go @@ -0,0 +1,156 @@ +//go:build !windows + +package local + +import ( + "bufio" + "context" + "net" + "net/netip" + "os" + "strings" + "time" + + "github.com/miekg/dns" +) + +func dnsReadConfig(_ context.Context, name string) *dnsConfig { + conf := &dnsConfig{ + ndots: 1, + timeout: 5 * time.Second, + attempts: 2, + } + file, err := os.Open(name) + if err != nil { + conf.servers = defaultNS + conf.search = dnsDefaultSearch() + conf.err = err + return conf + } + defer file.Close() + fi, err := file.Stat() + if err == nil { + conf.mtime = fi.ModTime() + } else { + conf.servers = defaultNS + conf.search = dnsDefaultSearch() + conf.err = err + return conf + } + reader := bufio.NewReader(file) + var ( + prefix []byte + line []byte + isPrefix bool + ) + for { + line, isPrefix, err = reader.ReadLine() + if err != nil { + break + } + if isPrefix { + prefix = append(prefix, line...) + continue + } else if len(prefix) > 0 { + line = append(prefix, line...) + prefix = nil + } + if len(line) > 0 && (line[0] == ';' || line[0] == '#') { + continue + } + f := strings.Fields(string(line)) + if len(f) < 1 { + continue + } + switch f[0] { + case "nameserver": + if len(f) > 1 && len(conf.servers) < 3 { + if _, err := netip.ParseAddr(f[1]); err == nil { + conf.servers = append(conf.servers, net.JoinHostPort(f[1], "53")) + } + } + case "domain": + if len(f) > 1 { + conf.search = []string{dns.Fqdn(f[1])} + } + + case "search": + conf.search = make([]string, 0, len(f)-1) + for i := 1; i < len(f); i++ { + name := dns.Fqdn(f[i]) + if name == "." { + continue + } + conf.search = append(conf.search, name) + } + + case "options": + for _, s := range f[1:] { + switch { + case strings.HasPrefix(s, "ndots:"): + n, _, _ := dtoi(s[6:]) + if n < 0 { + n = 0 + } else if n > 15 { + n = 15 + } + conf.ndots = n + case strings.HasPrefix(s, "timeout:"): + n, _, _ := dtoi(s[8:]) + if n < 1 { + n = 1 + } + conf.timeout = time.Duration(n) * time.Second + case strings.HasPrefix(s, "attempts:"): + n, _, _ := dtoi(s[9:]) + if n < 1 { + n = 1 + } + conf.attempts = n + case s == "rotate": + conf.rotate = true + case s == "single-request" || s == "single-request-reopen": + conf.singleRequest = true + case s == "use-vc" || s == "usevc" || s == "tcp": + conf.useTCP = true + case s == "trust-ad": + conf.trustAD = true + case s == "edns0": + case s == "no-reload": + conf.noReload = true + default: + conf.unknownOpt = true + } + } + + case "lookup": + conf.lookup = f[1:] + + default: + conf.unknownOpt = true + } + } + if len(conf.servers) == 0 { + conf.servers = defaultNS + } + if len(conf.search) == 0 { + conf.search = dnsDefaultSearch() + } + return conf +} + +const big = 0xFFFFFF + +func dtoi(s string) (n int, i int, ok bool) { + n = 0 + for i = 0; i < len(s) && '0' <= s[i] && s[i] <= '9'; i++ { + n = n*10 + int(s[i]-'0') + if n >= big { + return big, i, false + } + } + if i == 0 { + return 0, 0, false + } + return n, i, true +} diff --git a/dns/transport/local/resolv_windows.go b/dns/transport/local/resolv_windows.go new file mode 100644 index 00000000..04b8d4ef --- /dev/null +++ b/dns/transport/local/resolv_windows.go @@ -0,0 +1,118 @@ +package local + +import ( + "context" + "net" + "net/netip" + "os" + "strconv" + "syscall" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/service" + + "golang.org/x/sys/windows" +) + +func dnsReadConfig(ctx context.Context, _ string) *dnsConfig { + conf := &dnsConfig{ + ndots: 1, + timeout: 5 * time.Second, + attempts: 2, + } + defer func() { + if len(conf.servers) == 0 { + conf.servers = defaultNS + } + }() + addresses, err := adapterAddresses() + if err != nil { + return nil + } + var dnsAddresses []struct { + ifName string + netip.Addr + } + for _, address := range addresses { + if address.OperStatus != windows.IfOperStatusUp { + continue + } + if address.IfType == windows.IF_TYPE_TUNNEL { + continue + } + if address.FirstGatewayAddress == nil { + continue + } + for dnsServerAddress := address.FirstDnsServerAddress; dnsServerAddress != nil; dnsServerAddress = dnsServerAddress.Next { + rawSockaddr, err := dnsServerAddress.Address.Sockaddr.Sockaddr() + if err != nil { + continue + } + var dnsServerAddr netip.Addr + switch sockaddr := rawSockaddr.(type) { + case *syscall.SockaddrInet4: + dnsServerAddr = netip.AddrFrom4(sockaddr.Addr) + case *syscall.SockaddrInet6: + if sockaddr.Addr[0] == 0xfe && sockaddr.Addr[1] == 0xc0 { + // fec0/10 IPv6 addresses are site local anycast DNS + // addresses Microsoft sets by default if no other + // IPv6 DNS address is set. Site local anycast is + // deprecated since 2004, see + // https://datatracker.ietf.org/doc/html/rfc3879 + continue + } + dnsServerAddr = netip.AddrFrom16(sockaddr.Addr) + if sockaddr.ZoneId != 0 { + dnsServerAddr = dnsServerAddr.WithZone(strconv.FormatInt(int64(sockaddr.ZoneId), 10)) + } + default: + // Unexpected type. + continue + } + dnsAddresses = append(dnsAddresses, struct { + ifName string + netip.Addr + }{ifName: windows.UTF16PtrToString(address.FriendlyName), Addr: dnsServerAddr}) + } + } + var myInterface string + if networkManager := service.FromContext[adapter.NetworkManager](ctx); networkManager != nil { + myInterface = networkManager.InterfaceMonitor().MyInterface() + } + for _, address := range dnsAddresses { + if address.ifName == myInterface { + continue + } + conf.servers = append(conf.servers, net.JoinHostPort(address.String(), "53")) + } + return conf +} + +func adapterAddresses() ([]*windows.IpAdapterAddresses, error) { + var b []byte + l := uint32(15000) // recommended initial size + for { + b = make([]byte, l) + const flags = windows.GAA_FLAG_INCLUDE_PREFIX | windows.GAA_FLAG_INCLUDE_GATEWAYS + err := windows.GetAdaptersAddresses(syscall.AF_UNSPEC, flags, 0, (*windows.IpAdapterAddresses)(unsafe.Pointer(&b[0])), &l) + if err == nil { + if l == 0 { + return nil, nil + } + break + } + if err.(syscall.Errno) != syscall.ERROR_BUFFER_OVERFLOW { + return nil, os.NewSyscallError("getadaptersaddresses", err) + } + if l <= uint32(len(b)) { + return nil, os.NewSyscallError("getadaptersaddresses", err) + } + } + var aas []*windows.IpAdapterAddresses + for aa := (*windows.IpAdapterAddresses)(unsafe.Pointer(&b[0])); aa != nil; aa = aa.Next { + aas = append(aas, aa) + } + return aas, nil +} diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go new file mode 100644 index 00000000..c3a5ca81 --- /dev/null +++ b/dns/transport/quic/http3.go @@ -0,0 +1,205 @@ +package quic + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "net/url" + "strconv" + "sync" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + sHTTP "github.com/sagernet/sing/protocol/http" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*HTTP3Transport)(nil) + +func RegisterHTTP3Transport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteHTTPSDNSServerOptions](registry, C.DNSTypeHTTP3, NewHTTP3) +} + +type HTTP3Transport struct { + dns.TransportAdapter + logger logger.ContextLogger + dialer N.Dialer + destination *url.URL + headers http.Header + serverAddr M.Socksaddr + tlsConfig *tls.STDConfig + transportAccess sync.Mutex + transport *http3.Transport +} + +func NewHTTP3(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteHTTPSDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions) + if err != nil { + return nil, err + } + tlsOptions := common.PtrValueOrDefault(options.TLS) + tlsOptions.Enabled = true + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, tlsOptions) + if err != nil { + return nil, err + } + stdConfig, err := tlsConfig.STDConfig() + if err != nil { + return nil, err + } + headers := options.Headers.Build() + host := headers.Get("Host") + if host != "" { + headers.Del("Host") + } else { + if tlsConfig.ServerName() != "" { + host = tlsConfig.ServerName() + } else { + host = options.Server + } + } + destinationURL := url.URL{ + Scheme: "https", + Host: host, + } + if destinationURL.Host == "" { + destinationURL.Host = options.Server + } + if options.ServerPort != 0 && options.ServerPort != 443 { + destinationURL.Host = net.JoinHostPort(destinationURL.Host, strconv.Itoa(int(options.ServerPort))) + } + path := options.Path + if path == "" { + path = "/dns-query" + } + err = sHTTP.URLSetPath(&destinationURL, path) + if err != nil { + return nil, err + } + serverAddr := options.DNSServerAddressOptions.Build() + if serverAddr.Port == 0 { + serverAddr.Port = 443 + } + if !serverAddr.IsValid() { + return nil, E.New("invalid server address: ", serverAddr) + } + t := &HTTP3Transport{ + TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeHTTP3, tag, options.RemoteDNSServerOptions), + logger: logger, + dialer: transportDialer, + destination: &destinationURL, + headers: headers, + serverAddr: serverAddr, + tlsConfig: stdConfig, + } + t.transport = t.newTransport() + return t, nil +} + +func (t *HTTP3Transport) newTransport() *http3.Transport { + return &http3.Transport{ + Dial: func(ctx context.Context, addr string, tlsCfg *tls.STDConfig, cfg *quic.Config) (*quic.Conn, error) { + conn, dialErr := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if dialErr != nil { + return nil, dialErr + } + quicConn, dialErr := quic.DialEarly(ctx, bufio.NewUnbindPacketConn(conn), conn.RemoteAddr(), tlsCfg, cfg) + if dialErr != nil { + conn.Close() + return nil, dialErr + } + return quicConn, nil + }, + TLSClientConfig: t.tlsConfig, + } +} + +func (t *HTTP3Transport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return dialer.InitializeDetour(t.dialer) +} + +func (t *HTTP3Transport) Close() error { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + return t.transport.Close() +} + +func (t *HTTP3Transport) Reset() { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + t.transport.Close() + t.transport = t.newTransport() +} + +func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + exMessage := *message + exMessage.Id = 0 + exMessage.Compress = true + requestBuffer := buf.NewSize(1 + message.Len()) + rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes()) + if err != nil { + requestBuffer.Release() + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage)) + if err != nil { + requestBuffer.Release() + return nil, err + } + request.Header = t.headers.Clone() + request.Header.Set("Content-Type", transport.MimeType) + request.Header.Set("Accept", transport.MimeType) + t.transportAccess.Lock() + currentTransport := t.transport + t.transportAccess.Unlock() + response, err := currentTransport.RoundTrip(request) + requestBuffer.Release() + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, E.New("unexpected status: ", response.Status) + } + var responseMessage mDNS.Msg + if response.ContentLength > 0 { + responseBuffer := buf.NewSize(int(response.ContentLength)) + defer responseBuffer.Release() + _, err = responseBuffer.ReadFullFrom(response.Body, int(response.ContentLength)) + if err != nil { + return nil, err + } + err = responseMessage.Unpack(responseBuffer.Bytes()) + } else { + rawMessage, err = io.ReadAll(response.Body) + if err != nil { + return nil, err + } + err = responseMessage.Unpack(rawMessage) + } + if err != nil { + return nil, err + } + return &responseMessage, nil +} diff --git a/dns/transport/quic/quic.go b/dns/transport/quic/quic.go new file mode 100644 index 00000000..26461006 --- /dev/null +++ b/dns/transport/quic/quic.go @@ -0,0 +1,209 @@ +package quic + +import ( + "context" + "errors" + "os" + + "github.com/sagernet/quic-go" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + sQUIC "github.com/sagernet/sing-quic" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*Transport)(nil) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteTLSDNSServerOptions](registry, C.DNSTypeQUIC, NewQUIC) +} + +type Transport struct { + *transport.BaseTransport + + ctx context.Context + dialer N.Dialer + serverAddr M.Socksaddr + tlsConfig tls.Config + + connector *transport.Connector[*quic.Conn] +} + +func NewQUIC(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions) + if err != nil { + return nil, err + } + tlsOptions := common.PtrValueOrDefault(options.TLS) + tlsOptions.Enabled = true + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, tlsOptions) + if err != nil { + return nil, err + } + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{"doq"}) + } + serverAddr := options.DNSServerAddressOptions.Build() + if serverAddr.Port == 0 { + serverAddr.Port = 853 + } + if !serverAddr.IsValid() { + return nil, E.New("invalid server address: ", serverAddr) + } + + t := &Transport{ + BaseTransport: transport.NewBaseTransport( + dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeQUIC, tag, options.RemoteDNSServerOptions), + logger, + ), + ctx: ctx, + dialer: transportDialer, + serverAddr: serverAddr, + tlsConfig: tlsConfig, + } + + t.connector = transport.NewConnector(t.CloseContext(), t.dial, transport.ConnectorCallbacks[*quic.Conn]{ + IsClosed: func(connection *quic.Conn) bool { + return common.Done(connection.Context()) + }, + Close: func(connection *quic.Conn) { + connection.CloseWithError(0, "") + }, + Reset: func(connection *quic.Conn) { + connection.CloseWithError(0, "") + }, + }) + + return t, nil +} + +func (t *Transport) dial(ctx context.Context) (*quic.Conn, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + earlyConnection, err := sQUIC.DialEarly( + ctx, + bufio.NewUnbindPacketConn(conn), + t.serverAddr.UDPAddr(), + t.tlsConfig, + nil, + ) + if err != nil { + conn.Close() + return nil, E.Cause(err, "establish QUIC connection") + } + return earlyConnection, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := t.SetStarted() + if err != nil { + return err + } + return dialer.InitializeDetour(t.dialer) +} + +func (t *Transport) Close() error { + return E.Errors(t.BaseTransport.Close(), t.connector.Close()) +} + +func (t *Transport) Reset() { + t.connector.Reset() +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if !t.BeginQuery() { + return nil, transport.ErrTransportClosed + } + defer t.EndQuery() + + var ( + conn *quic.Conn + err error + response *mDNS.Msg + ) + for i := 0; i < 2; i++ { + conn, err = t.connector.Get(ctx) + if err != nil { + return nil, err + } + response, err = t.exchange(ctx, message, conn) + if err == nil { + return response, nil + } else if !isQUICRetryError(err) { + return nil, err + } else { + t.connector.Reset() + continue + } + } + return nil, err +} + +func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, conn *quic.Conn) (*mDNS.Msg, error) { + stream, err := conn.OpenStreamSync(ctx) + if err != nil { + return nil, E.Cause(err, "open stream") + } + defer stream.CancelRead(0) + err = transport.WriteMessage(stream, 0, message) + if err != nil { + stream.Close() + return nil, E.Cause(err, "write request") + } + stream.Close() + response, err := transport.ReadMessage(stream) + if err != nil { + return nil, E.Cause(err, "read response") + } + return response, nil +} + +// https://github.com/AdguardTeam/dnsproxy/blob/fd1868577652c639cce3da00e12ca548f421baf1/upstream/upstream_quic.go#L394 +func isQUICRetryError(err error) (ok bool) { + if errors.Is(err, os.ErrClosed) { + return true + } + + var qAppErr *quic.ApplicationError + if errors.As(err, &qAppErr) && qAppErr.ErrorCode == 0 { + return true + } + + var qIdleErr *quic.IdleTimeoutError + if errors.As(err, &qIdleErr) { + return true + } + + var resetErr *quic.StatelessResetError + if errors.As(err, &resetErr) { + return true + } + + var qTransportError *quic.TransportError + if errors.As(err, &qTransportError) && qTransportError.ErrorCode == quic.NoError { + return true + } + + if errors.Is(err, quic.Err0RTTRejected) { + return true + } + + return false +} diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go new file mode 100644 index 00000000..59333de8 --- /dev/null +++ b/dns/transport/tcp.go @@ -0,0 +1,119 @@ +package transport + +import ( + "context" + "encoding/binary" + "io" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*TCPTransport)(nil) + +func RegisterTCP(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) +} + +type TCPTransport struct { + dns.TransportAdapter + dialer N.Dialer + serverAddr M.Socksaddr +} + +func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options) + if err != nil { + return nil, err + } + serverAddr := options.DNSServerAddressOptions.Build() + if serverAddr.Port == 0 { + serverAddr.Port = 53 + } + if !serverAddr.IsValid() { + return nil, E.New("invalid server address: ", serverAddr) + } + return &TCPTransport{ + TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), + dialer: transportDialer, + serverAddr: serverAddr, + }, nil +} + +func (t *TCPTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return dialer.InitializeDetour(t.dialer) +} + +func (t *TCPTransport) Close() error { + return nil +} + +func (t *TCPTransport) Reset() { +} + +func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TCP connection") + } + defer conn.Close() + err = WriteMessage(conn, 0, message) + if err != nil { + return nil, E.Cause(err, "write request") + } + response, err := ReadMessage(conn) + if err != nil { + return nil, E.Cause(err, "read response") + } + return response, nil +} + +func ReadMessage(reader io.Reader) (*mDNS.Msg, error) { + var responseLen uint16 + err := binary.Read(reader, binary.BigEndian, &responseLen) + if err != nil { + return nil, err + } + if responseLen < 10 { + return nil, mDNS.ErrShortRead + } + buffer := buf.NewSize(int(responseLen)) + defer buffer.Release() + _, err = buffer.ReadFullFrom(reader, int(responseLen)) + if err != nil { + return nil, err + } + var message mDNS.Msg + err = message.Unpack(buffer.Bytes()) + return &message, err +} + +func WriteMessage(writer io.Writer, messageId uint16, message *mDNS.Msg) error { + requestLen := message.Len() + buffer := buf.NewSize(3 + requestLen) + defer buffer.Release() + common.Must(binary.Write(buffer, binary.BigEndian, uint16(requestLen))) + exMessage := *message + exMessage.Id = messageId + exMessage.Compress = true + rawMessage, err := exMessage.PackBuffer(buffer.FreeBytes()) + if err != nil { + return err + } + buffer.Truncate(2 + len(rawMessage)) + return common.Error(writer.Write(buffer.Bytes())) +} diff --git a/dns/transport/tls.go b/dns/transport/tls.go new file mode 100644 index 00000000..4d463296 --- /dev/null +++ b/dns/transport/tls.go @@ -0,0 +1,154 @@ +package transport + +import ( + "context" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*TLSTransport)(nil) + +func RegisterTLS(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteTLSDNSServerOptions](registry, C.DNSTypeTLS, NewTLS) +} + +type TLSTransport struct { + *BaseTransport + + dialer tls.Dialer + serverAddr M.Socksaddr + tlsConfig tls.Config + access sync.Mutex + connections list.List[*tlsDNSConn] +} + +type tlsDNSConn struct { + tls.Conn + queryId uint16 +} + +func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions) + if err != nil { + return nil, err + } + tlsOptions := common.PtrValueOrDefault(options.TLS) + tlsOptions.Enabled = true + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, tlsOptions) + if err != nil { + return nil, err + } + serverAddr := options.DNSServerAddressOptions.Build() + if serverAddr.Port == 0 { + serverAddr.Port = 853 + } + if !serverAddr.IsValid() { + return nil, E.New("invalid server address: ", serverAddr) + } + return NewTLSRaw(logger, dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTLS, tag, options.RemoteDNSServerOptions), transportDialer, serverAddr, tlsConfig), nil +} + +func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config) *TLSTransport { + return &TLSTransport{ + BaseTransport: NewBaseTransport(adapter, logger), + dialer: tls.NewDialer(dialer, tlsConfig), + serverAddr: serverAddr, + tlsConfig: tlsConfig, + } +} + +func (t *TLSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := t.SetStarted() + if err != nil { + return err + } + return dialer.InitializeDetour(t.dialer) +} + +func (t *TLSTransport) Close() error { + t.access.Lock() + for connection := t.connections.Front(); connection != nil; connection = connection.Next() { + connection.Value.Close() + } + t.connections.Init() + t.access.Unlock() + return t.BaseTransport.Close() +} + +func (t *TLSTransport) Reset() { + t.access.Lock() + defer t.access.Unlock() + for connection := t.connections.Front(); connection != nil; connection = connection.Next() { + connection.Value.Close() + } + t.connections.Init() +} + +func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if !t.BeginQuery() { + return nil, ErrTransportClosed + } + defer t.EndQuery() + + t.access.Lock() + conn := t.connections.PopFront() + t.access.Unlock() + if conn != nil { + response, err := t.exchange(ctx, message, conn) + if err == nil { + return response, nil + } + t.Logger.DebugContext(ctx, "discarded pooled connection: ", err) + } + tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TLS connection") + } + return t.exchange(ctx, message, &tlsDNSConn{Conn: tlsConn}) +} + +func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + } + conn.queryId++ + err := WriteMessage(conn, conn.queryId, message) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write request") + } + response, err := ReadMessage(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "read response") + } + t.access.Lock() + if t.State() >= StateClosing { + t.access.Unlock() + conn.Close() + return response, nil + } + conn.SetDeadline(time.Time{}) + t.connections.PushBack(conn) + t.access.Unlock() + return response, nil +} diff --git a/dns/transport/udp.go b/dns/transport/udp.go new file mode 100644 index 00000000..a7272545 --- /dev/null +++ b/dns/transport/udp.go @@ -0,0 +1,258 @@ +package transport + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*UDPTransport)(nil) + +func RegisterUDP(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteDNSServerOptions](registry, C.DNSTypeUDP, NewUDP) +} + +type UDPTransport struct { + *BaseTransport + + dialer N.Dialer + serverAddr M.Socksaddr + udpSize atomic.Int32 + + connector *Connector[*Connection] + + callbackAccess sync.RWMutex + queryId uint16 + callbacks map[uint16]*udpCallback +} + +type udpCallback struct { + access sync.Mutex + response *mDNS.Msg + done chan struct{} +} + +func NewUDP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options) + if err != nil { + return nil, err + } + serverAddr := options.DNSServerAddressOptions.Build() + if serverAddr.Port == 0 { + serverAddr.Port = 53 + } + if !serverAddr.IsValid() { + return nil, E.New("invalid server address: ", serverAddr) + } + return NewUDPRaw(logger, dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeUDP, tag, options), transportDialer, serverAddr), nil +} + +func NewUDPRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialerInstance N.Dialer, serverAddr M.Socksaddr) *UDPTransport { + t := &UDPTransport{ + BaseTransport: NewBaseTransport(adapter, logger), + dialer: dialerInstance, + serverAddr: serverAddr, + callbacks: make(map[uint16]*udpCallback), + } + t.udpSize.Store(2048) + t.connector = NewSingleflightConnector(t.CloseContext(), t.dial) + return t +} + +func (t *UDPTransport) dial(ctx context.Context) (*Connection, error) { + rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + conn := WrapConnection(rawConn) + go t.recvLoop(conn) + return conn, nil +} + +func (t *UDPTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := t.SetStarted() + if err != nil { + return err + } + return dialer.InitializeDetour(t.dialer) +} + +func (t *UDPTransport) Close() error { + return E.Errors(t.BaseTransport.Close(), t.connector.Close()) +} + +func (t *UDPTransport) Reset() { + t.connector.Reset() +} + +func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { + start := t.queryId + for { + t.queryId++ + if _, exists := t.callbacks[t.queryId]; !exists { + return t.queryId, nil + } + if t.queryId == start { + return 0, E.New("no available query ID") + } + } +} + +func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if !t.BeginQuery() { + return nil, ErrTransportClosed + } + defer t.EndQuery() + + response, err := t.exchange(ctx, message) + if err != nil { + return nil, err + } + if response.Truncated { + t.Logger.InfoContext(ctx, "response truncated, retrying with TCP") + return t.exchangeTCP(ctx, message) + } + return response, nil +} + +func (t *UDPTransport) exchangeTCP(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TCP connection") + } + defer conn.Close() + err = WriteMessage(conn, message.Id, message) + if err != nil { + return nil, E.Cause(err, "write request") + } + response, err := ReadMessage(conn) + if err != nil { + return nil, E.Cause(err, "read response") + } + return response, nil +} + +func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if edns0Opt := message.IsEdns0(); edns0Opt != nil { + udpSize := int32(edns0Opt.UDPSize()) + for { + current := t.udpSize.Load() + if udpSize <= current { + break + } + if t.udpSize.CompareAndSwap(current, udpSize) { + t.connector.Reset() + break + } + } + } + + conn, err := t.connector.Get(ctx) + if err != nil { + return nil, err + } + + callback := &udpCallback{ + done: make(chan struct{}), + } + + t.callbackAccess.Lock() + queryId, err := t.nextAvailableQueryId() + if err != nil { + t.callbackAccess.Unlock() + return nil, err + } + t.callbacks[queryId] = callback + t.callbackAccess.Unlock() + + defer func() { + t.callbackAccess.Lock() + delete(t.callbacks, queryId) + t.callbackAccess.Unlock() + }() + + buffer := buf.NewSize(1 + message.Len()) + defer buffer.Release() + + exMessage := *message + exMessage.Compress = true + originalId := message.Id + exMessage.Id = queryId + + rawMessage, err := exMessage.PackBuffer(buffer.FreeBytes()) + if err != nil { + return nil, err + } + + _, err = conn.Write(rawMessage) + if err != nil { + conn.CloseWithError(err) + return nil, E.Cause(err, "write request") + } + + select { + case <-callback.done: + callback.response.Id = originalId + return callback.response, nil + case <-conn.Done(): + return nil, conn.CloseError() + case <-t.CloseContext().Done(): + return nil, ErrTransportClosed + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (t *UDPTransport) recvLoop(conn *Connection) { + for { + buffer := buf.NewSize(int(t.udpSize.Load())) + _, err := buffer.ReadOnceFrom(conn) + if err != nil { + buffer.Release() + conn.CloseWithError(err) + return + } + + var message mDNS.Msg + err = message.Unpack(buffer.Bytes()) + buffer.Release() + if err != nil { + t.Logger.Debug("discarded malformed UDP response: ", err) + continue + } + + t.callbackAccess.RLock() + callback, loaded := t.callbacks[message.Id] + t.callbackAccess.RUnlock() + + if !loaded { + continue + } + + callback.access.Lock() + select { + case <-callback.done: + default: + callback.response = &message + close(callback.done) + } + callback.access.Unlock() + } +} diff --git a/dns/transport_adapter.go b/dns/transport_adapter.go new file mode 100644 index 00000000..1e6620f2 --- /dev/null +++ b/dns/transport_adapter.go @@ -0,0 +1,55 @@ +package dns + +import ( + "github.com/sagernet/sing-box/option" +) + +type TransportAdapter struct { + transportType string + transportTag string + dependencies []string +} + +func NewTransportAdapter(transportType string, transportTag string, dependencies []string) TransportAdapter { + return TransportAdapter{ + transportType: transportType, + transportTag: transportTag, + dependencies: dependencies, + } +} + +func NewTransportAdapterWithLocalOptions(transportType string, transportTag string, localOptions option.LocalDNSServerOptions) TransportAdapter { + var dependencies []string + if localOptions.DomainResolver != nil && localOptions.DomainResolver.Server != "" { + dependencies = append(dependencies, localOptions.DomainResolver.Server) + } + return TransportAdapter{ + transportType: transportType, + transportTag: transportTag, + dependencies: dependencies, + } +} + +func NewTransportAdapterWithRemoteOptions(transportType string, transportTag string, remoteOptions option.RemoteDNSServerOptions) TransportAdapter { + var dependencies []string + if remoteOptions.DomainResolver != nil && remoteOptions.DomainResolver.Server != "" { + dependencies = append(dependencies, remoteOptions.DomainResolver.Server) + } + return TransportAdapter{ + transportType: transportType, + transportTag: transportTag, + dependencies: dependencies, + } +} + +func (a *TransportAdapter) Type() string { + return a.transportType +} + +func (a *TransportAdapter) Tag() string { + return a.transportTag +} + +func (a *TransportAdapter) Dependencies() []string { + return a.dependencies +} diff --git a/dns/transport_dialer.go b/dns/transport_dialer.go new file mode 100644 index 00000000..971002ac --- /dev/null +++ b/dns/transport_dialer.go @@ -0,0 +1,26 @@ +package dns + +import ( + "context" + + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/option" + N "github.com/sagernet/sing/common/network" +) + +func NewLocalDialer(ctx context.Context, options option.LocalDNSServerOptions) (N.Dialer, error) { + return dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + DirectResolver: true, + }) +} + +func NewRemoteDialer(ctx context.Context, options option.RemoteDNSServerOptions) (N.Dialer, error) { + return dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain(), + DirectResolver: true, + }) +} diff --git a/dns/transport_manager.go b/dns/transport_manager.go new file mode 100644 index 00000000..e289ccea --- /dev/null +++ b/dns/transport_manager.go @@ -0,0 +1,300 @@ +package dns + +import ( + "context" + "io" + "os" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.DNSTransportManager = (*TransportManager)(nil) + +type TransportManager struct { + logger log.ContextLogger + registry adapter.DNSTransportRegistry + outbound adapter.OutboundManager + defaultTag string + access sync.RWMutex + started bool + stage adapter.StartStage + transports []adapter.DNSTransport + transportByTag map[string]adapter.DNSTransport + dependByTag map[string][]string + defaultTransport adapter.DNSTransport + defaultTransportFallback func() (adapter.DNSTransport, error) + fakeIPTransport adapter.FakeIPTransport +} + +func NewTransportManager(logger logger.ContextLogger, registry adapter.DNSTransportRegistry, outbound adapter.OutboundManager, defaultTag string) *TransportManager { + return &TransportManager{ + logger: logger, + registry: registry, + outbound: outbound, + defaultTag: defaultTag, + transportByTag: make(map[string]adapter.DNSTransport), + dependByTag: make(map[string][]string), + } +} + +func (m *TransportManager) Initialize(defaultTransportFallback func() (adapter.DNSTransport, error)) { + m.defaultTransportFallback = defaultTransportFallback +} + +func (m *TransportManager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + if stage == adapter.StartStateStart { + if m.defaultTag != "" && m.defaultTransport == nil { + m.access.Unlock() + return E.New("default DNS server not found: ", m.defaultTag) + } + if m.defaultTransport == nil { + defaultTransport, err := m.defaultTransportFallback() + if err != nil { + m.access.Unlock() + return E.Cause(err, "default DNS server fallback") + } + m.transports = append(m.transports, defaultTransport) + m.transportByTag[defaultTransport.Tag()] = defaultTransport + m.defaultTransport = defaultTransport + } + transports := m.transports + m.access.Unlock() + return m.startTransports(transports) + } else { + transports := m.transports + m.access.Unlock() + for _, outbound := range transports { + err := adapter.LegacyStart(outbound, stage) + if err != nil { + return E.Cause(err, stage, " dns/", outbound.Type(), "[", outbound.Tag(), "]") + } + } + } + return nil +} + +func (m *TransportManager) startTransports(transports []adapter.DNSTransport) error { + monitor := taskmonitor.New(m.logger, C.StartTimeout) + started := make(map[string]bool) + for { + canContinue := false + startOne: + for _, transportToStart := range transports { + transportTag := transportToStart.Tag() + if started[transportTag] { + continue + } + dependencies := transportToStart.Dependencies() + for _, dependency := range dependencies { + if !started[dependency] { + continue startOne + } + } + started[transportTag] = true + canContinue = true + if starter, isStarter := transportToStart.(adapter.Lifecycle); isStarter { + monitor.Start("start dns/", transportToStart.Type(), "[", transportTag, "]") + err := starter.Start(adapter.StartStateStart) + monitor.Finish() + if err != nil { + return E.Cause(err, "start dns/", transportToStart.Type(), "[", transportTag, "]") + } + } + } + if len(started) == len(transports) { + break + } + if canContinue { + continue + } + currentTransport := common.Find(transports, func(it adapter.DNSTransport) bool { + return !started[it.Tag()] + }) + var lintTransport func(oTree []string, oCurrent adapter.DNSTransport) error + lintTransport = func(oTree []string, oCurrent adapter.DNSTransport) error { + problemTransportTag := common.Find(oCurrent.Dependencies(), func(it string) bool { + return !started[it] + }) + if common.Contains(oTree, problemTransportTag) { + return E.New("circular server dependency: ", strings.Join(oTree, " -> "), " -> ", problemTransportTag) + } + m.access.Lock() + problemTransport := m.transportByTag[problemTransportTag] + m.access.Unlock() + if problemTransport == nil { + return E.New("dependency[", problemTransportTag, "] not found for server[", oCurrent.Tag(), "]") + } + return lintTransport(append(oTree, problemTransportTag), problemTransport) + } + return lintTransport([]string{currentTransport.Tag()}, currentTransport) + } + return nil +} + +func (m *TransportManager) Close() error { + monitor := taskmonitor.New(m.logger, C.StopTimeout) + m.access.Lock() + if !m.started { + m.access.Unlock() + return nil + } + m.started = false + transports := m.transports + m.transports = nil + m.access.Unlock() + var err error + for _, transport := range transports { + if closer, isCloser := transport.(io.Closer); isCloser { + monitor.Start("close server/", transport.Type(), "[", transport.Tag(), "]") + err = E.Append(err, closer.Close(), func(err error) error { + return E.Cause(err, "close server/", transport.Type(), "[", transport.Tag(), "]") + }) + monitor.Finish() + } + } + return nil +} + +func (m *TransportManager) Transports() []adapter.DNSTransport { + m.access.RLock() + defer m.access.RUnlock() + return m.transports +} + +func (m *TransportManager) Transport(tag string) (adapter.DNSTransport, bool) { + m.access.RLock() + outbound, found := m.transportByTag[tag] + m.access.RUnlock() + return outbound, found +} + +func (m *TransportManager) Default() adapter.DNSTransport { + m.access.RLock() + defer m.access.RUnlock() + return m.defaultTransport +} + +func (m *TransportManager) FakeIP() adapter.FakeIPTransport { + m.access.RLock() + defer m.access.RUnlock() + return m.fakeIPTransport +} + +func (m *TransportManager) Remove(tag string) error { + m.access.Lock() + defer m.access.Unlock() + transport, found := m.transportByTag[tag] + if !found { + return os.ErrInvalid + } + delete(m.transportByTag, tag) + index := common.Index(m.transports, func(it adapter.DNSTransport) bool { + return it == transport + }) + if index == -1 { + panic("invalid inbound index") + } + m.transports = append(m.transports[:index], m.transports[index+1:]...) + started := m.started + if m.defaultTransport == transport { + if len(m.transports) > 0 { + nextTransport := m.transports[0] + if nextTransport.Type() != C.DNSTypeFakeIP { + return E.New("default server cannot be fakeip") + } + m.defaultTransport = nextTransport + m.logger.Info("updated default server to ", m.defaultTransport.Tag()) + } else { + m.defaultTransport = nil + } + } + dependBy := m.dependByTag[tag] + if len(dependBy) > 0 { + return E.New("server[", tag, "] is depended by ", strings.Join(dependBy, ", ")) + } + dependencies := transport.Dependencies() + for _, dependency := range dependencies { + if len(m.dependByTag[dependency]) == 1 { + delete(m.dependByTag, dependency) + } else { + m.dependByTag[dependency] = common.Filter(m.dependByTag[dependency], func(it string) bool { + return it != tag + }) + } + } + if started { + transport.Close() + } + return nil +} + +func (m *TransportManager) Create(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) error { + if tag == "" { + return os.ErrInvalid + } + transport, err := m.registry.CreateDNSTransport(ctx, logger, tag, transportType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + for _, stage := range adapter.ListStartStages { + err = adapter.LegacyStart(transport, stage) + if err != nil { + return E.Cause(err, stage, " dns/", transport.Type(), "[", transport.Tag(), "]") + } + } + } + if existsTransport, loaded := m.transportByTag[tag]; loaded { + if m.started { + err = common.Close(existsTransport) + if err != nil { + return E.Cause(err, "close dns/", existsTransport.Type(), "[", existsTransport.Tag(), "]") + } + } + existsIndex := common.Index(m.transports, func(it adapter.DNSTransport) bool { + return it == existsTransport + }) + if existsIndex == -1 { + panic("invalid inbound index") + } + m.transports = append(m.transports[:existsIndex], m.transports[existsIndex+1:]...) + } + m.transports = append(m.transports, transport) + m.transportByTag[tag] = transport + dependencies := transport.Dependencies() + for _, dependency := range dependencies { + m.dependByTag[dependency] = append(m.dependByTag[dependency], tag) + } + if tag == m.defaultTag || (m.defaultTag == "" && m.defaultTransport == nil) { + if transport.Type() == C.DNSTypeFakeIP { + return E.New("default server cannot be fakeip") + } + m.defaultTransport = transport + if m.started { + m.logger.Info("updated default server to ", transport.Tag()) + } + } + if transport.Type() == C.DNSTypeFakeIP { + if m.fakeIPTransport != nil { + return E.New("multiple fakeip server are not supported") + } + m.fakeIPTransport = transport.(adapter.FakeIPTransport) + } + return nil +} diff --git a/dns/transport_registry.go b/dns/transport_registry.go new file mode 100644 index 00000000..d838158b --- /dev/null +++ b/dns/transport_registry.go @@ -0,0 +1,72 @@ +package dns + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type TransportConstructorFunc[T any] func(ctx context.Context, logger log.ContextLogger, tag string, options T) (adapter.DNSTransport, error) + +func RegisterTransport[Options any](registry *TransportRegistry, transportType string, constructor TransportConstructorFunc[Options]) { + registry.register(transportType, func() any { + return new(Options) + }, func(ctx context.Context, logger log.ContextLogger, tag string, rawOptions any) (adapter.DNSTransport, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.DNSTransportRegistry = (*TransportRegistry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, logger log.ContextLogger, tag string, options any) (adapter.DNSTransport, error) +) + +type TransportRegistry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructors map[string]constructorFunc +} + +func NewTransportRegistry() *TransportRegistry { + return &TransportRegistry{ + optionsType: make(map[string]optionsConstructorFunc), + constructors: make(map[string]constructorFunc), + } +} + +func (r *TransportRegistry) CreateOptions(transportType string) (any, bool) { + r.access.Lock() + defer r.access.Unlock() + optionsConstructor, loaded := r.optionsType[transportType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (r *TransportRegistry) CreateDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) (adapter.DNSTransport, error) { + r.access.Lock() + defer r.access.Unlock() + constructor, loaded := r.constructors[transportType] + if !loaded { + return nil, E.New("transport type not found: " + transportType) + } + return constructor(ctx, logger, tag, options) +} + +func (r *TransportRegistry) register(transportType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + r.access.Lock() + defer r.access.Unlock() + r.optionsType[transportType] = optionsConstructor + r.constructors[transportType] = constructor +} diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..a4ffb2f2 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +sing-box.sagernet.org \ No newline at end of file diff --git a/docs/assets/icon.svg b/docs/assets/icon.svg new file mode 100644 index 00000000..146d085a --- /dev/null +++ b/docs/assets/icon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..707b2b71 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,4178 @@ +--- +icon: material/alert-decagram +--- + +#### 1.14.0-alpha.12 + +* Fix fake-ip DNS server should return SUCCESS when another address type is not configured +* Fixes and improvements + +#### 1.13.8 + +* Update naiveproxy to v147.0.7727.49-1 +* Fix fake-ip DNS server should return SUCCESS when another address type is not configured +* Fixes and improvements + +#### 1.14.0-alpha.11 + +* Add optimistic DNS cache **1** +* Update NaiveProxy to 147.0.7727.49 +* Fixes and improvements + +**1**: + +Optimistic DNS cache returns an expired cached response immediately while +refreshing it in the background, reducing tail latency for repeated +queries. Enabled via [`optimistic`](/configuration/dns/#optimistic) +in DNS options, and can be persisted across restarts with the new +[`store_dns`](/configuration/experimental/cache-file/#store_dns) cache +file option. A per-query +[`disable_optimistic_cache`](/configuration/dns/rule_action/#disable_optimistic_cache) +field is also available on DNS rule actions and the `resolve` route rule +action. + +This deprecates the `independent_cache` DNS option (the DNS cache now +always keys by transport) and the `store_rdrc` cache file option +(replaced by `store_dns`); both will be removed in sing-box 1.16.0. +See [Migration](/migration/#migrate-independent-dns-cache). + +#### 1.14.0-alpha.10 + +* Add `evaluate` DNS rule action and Response Match Fields **1** +* `ip_version` and `query_type` now also take effect on internal DNS lookups **2** +* Add `package_name_regex` route, DNS and headless rule item **3** +* Add cloudflared inbound **4** +* Fixes and improvements + +**1**: + +Response Match Fields +([`response_rcode`](/configuration/dns/rule/#response_rcode), +[`response_answer`](/configuration/dns/rule/#response_answer), +[`response_ns`](/configuration/dns/rule/#response_ns), +and [`response_extra`](/configuration/dns/rule/#response_extra)) +match the evaluated DNS response. They are gated by the new +[`match_response`](/configuration/dns/rule/#match_response) field and +populated by a preceding +[`evaluate`](/configuration/dns/rule_action/#evaluate) DNS rule action; +the evaluated response can also be returned directly by a +[`respond`](/configuration/dns/rule_action/#respond) action. + +This deprecates the Legacy Address Filter Fields (`ip_cidr`, +`ip_is_private` without `match_response`) in DNS rules, the Legacy +`strategy` DNS rule action option, and the Legacy +`rule_set_ip_cidr_accept_empty` DNS rule item; all three will be removed +in sing-box 1.16.0. +See [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + +**2**: + +`ip_version` and `query_type` in DNS rules, together with `query_type` in +referenced rule-sets, now take effect on every DNS rule evaluation, +including matches from internal domain resolutions that do not target a +specific DNS server (for example a `resolve` route rule action without +`server` set). In earlier versions they were silently ignored in that +path. Combining these fields with any of the legacy DNS fields deprecated +in **1** in the same DNS configuration is no longer supported and is +rejected at startup. +See [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules). + +**3**: + +See [Route Rule](/configuration/route/rule/#package_name_regex), +[DNS Rule](/configuration/dns/rule/#package_name_regex) and +[Headless Rule](/configuration/rule-set/headless-rule/#package_name_regex). + +**4**: + +See [Cloudflared](/configuration/inbound/cloudflared/). + +#### 1.13.7 + +* Fixes and improvement + +#### 1.13.6 + +* Fixes and improvements + +#### 1.14.0-alpha.8 + +* Add BBR profile and hop interval randomization for Hysteria2 **1** +* Fixes and improvements + +**1**: + +See [Hysteria2 Inbound](/configuration/inbound/hysteria2/#bbr_profile) and [Hysteria2 Outbound](/configuration/outbound/hysteria2/#bbr_profile). + +#### 1.14.0-alpha.8 + +* Fixes and improvements + +#### 1.13.5 + +* Fixes and improvements + +#### 1.14.0-alpha.7 + +* Fixes and improvements + +#### 1.13.4 + +* Fixes and improvements + +#### 1.14.0-alpha.4 + +* Refactor ACME support to certificate provider system **1** +* Add Cloudflare Origin CA certificate provider **2** +* Add Tailscale certificate provider **3** +* Fixes and improvements + +**1**: + +See [Certificate Provider](/configuration/shared/certificate-provider/) and [Migration](/migration/#migrate-inline-acme-to-certificate-provider). + +**2**: + +See [Cloudflare Origin CA](/configuration/shared/certificate-provider/cloudflare-origin-ca). + +**3**: + +See [Tailscale](/configuration/shared/certificate-provider/tailscale). + +#### 1.13.3 + +* Add OpenWrt and Alpine APK packages to release **1** +* Backport to macOS 10.13 High Sierra **2** +* OCM service: Add WebSocket support for Responses API **3** +* Fixes and improvements + +**1**: + +Alpine APK files use `linux` in the filename to distinguish from OpenWrt APKs which use the `openwrt` prefix: + +- OpenWrt: `sing-box_{version}_openwrt_{architecture}.apk` +- Alpine: `sing-box_{version}_linux_{architecture}.apk` + +**2**: + +Legacy macOS binaries (with `-legacy-macos-10.13` suffix) now support +macOS 10.13 High Sierra, built using Go 1.25 with patches +from [SagerNet/go](https://github.com/SagerNet/go). + +**3**: + +See [OCM](/configuration/service/ocm). + +#### 1.12.24 + +* Fixes and improvements + +#### 1.14.0-alpha.2 + +* Add OpenWrt and Alpine APK packages to release **1** +* Backport to macOS 10.13 High Sierra **2** +* OCM service: Add WebSocket support for Responses API **3** +* Fixes and improvements + +**1**: + +Alpine APK files use `linux` in the filename to distinguish from OpenWrt APKs which use the `openwrt` prefix: + +- OpenWrt: `sing-box_{version}_openwrt_{architecture}.apk` +- Alpine: `sing-box_{version}_linux_{architecture}.apk` + +**2**: + +Legacy macOS binaries (with `-legacy-macos-10.13` suffix) now support +macOS 10.13 High Sierra, built using Go 1.25 with patches +from [SagerNet/go](https://github.com/SagerNet/go). + +**3**: + +See [OCM](/configuration/service/ocm). + +#### 1.14.0-alpha.1 + +* Add `source_mac_address` and `source_hostname` rule items **1** +* Add `include_mac_address` and `exclude_mac_address` TUN options **2** +* Update NaiveProxy to 145.0.7632.159 **3** +* Fixes and improvements + +**1**: + +New rule items for matching LAN devices by MAC address and hostname via neighbor resolution. +Supported on Linux, macOS, or in graphical clients on Android and macOS. + +See [Route Rule](/configuration/route/rule/#source_mac_address), [DNS Rule](/configuration/dns/rule/#source_mac_address) and [Neighbor Resolution](/configuration/shared/neighbor/). + +**2**: + +Limit or exclude devices from TUN routing by MAC address. +Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +See [TUN](/configuration/inbound/tun/#include_mac_address). + +**3**: + +This is not an official update from NaiveProxy. Instead, it's a Chromium codebase update maintained by Project S. + +#### 1.13.2 + +* Fixes and improvements + +#### 1.13.1 + +* Fixes and improvements + +#### 1.12.14 + +* Backport fixes + +#### 1.13.0 + +Important changes since 1.12: + +* Add NaiveProxy outbound **1** +* Add pre-match support for `auto_redirect` **2** +* Improve `auto_redirect` **3** +* Add Chrome Root Store certificate option **4** +* Add new options for ACME DNS-01 challenge providers **5** +* Add Wi-Fi state support for Linux and Windows **6** +* Add curve preferences, pinned public key SHA256, mTLS and ECH `query_server_name` for TLS options **7** +* Add kTLS support **8** +* Add ICMP echo (ping) proxy support **9** +* Add `interface_address`, `network_interface_address` and `default_interface_address` rule items **10** +* Add `preferred_by` route rule item **11** +* Improve `local` DNS server **12** +* Add `disable_tcp_keep_alive`, `tcp_keep_alive` and `tcp_keep_alive_interval` options for listen and dial fields **13** +* Add `bind_address_no_port` option for dial fields **14** +* Add system interface, relay server and advertise tags options for Tailscale endpoint **15** +* Add Claude Code Multiplexer service **16** +* Add OpenAI Codex Multiplexer service **17** +* Apple/Android: Refactor GUI +* Apple/Android: Add support for sharing configurations via [QRS](https://github.com/qifi-dev/qrs) +* Android: Add support for resisting VPN detection via Xposed +* Drop support for go1.23 **18** +* Drop support for Android 5.0 **19** +* Update uTLS to v1.8.2 **20** +* Update quic-go to v0.59.0 +* Update gVisor to v20250811 +* Update Tailscale to v1.92.4 + +**1**: + +NaiveProxy outbound now supports QUIC, ECH, UDP over TCP, and configurable QUIC congestion control. + +Only available on Apple platforms, Android, Windows and some Linux architectures. +Each Windows release includes `libcronet.dll` — +ensure this file is in the same directory as `sing-box.exe` or in a directory listed in `PATH`. + +See [NaiveProxy outbound](/configuration/outbound/naive/). + +**2**: + +`auto_redirect` now allows you to bypass sing-box for connections based on routing rules. + +A new rule action `bypass` is introduced to support this feature. When matched during pre-match, the connection will bypass sing-box and connect directly. + +This feature requires Linux with `auto_redirect` enabled. + +See [Pre-match](/configuration/shared/pre-match/) and [Rule Action](/configuration/route/rule_action/#bypass). + +**3**: + +`auto_redirect` now rejects MPTCP connections by default to fix compatibility issues. +You can change it to bypass sing-box via the new `exclude_mptcp` option. + +Adds a fallback iproute2 rule checked after system default rules (32766: main, 32767: default), +ensuring traffic is routed to the sing-box table when no route is found in system tables. +The rule index can be customized via `auto_redirect_iproute2_fallback_rule_index` (default: 32768). + +See [TUN](/configuration/inbound/tun/#exclude_mptcp). + +**4**: + +Adds `chrome` as a new certificate store option alongside `mozilla`. +Both stores filter out China-based CA certificates. + +See [Certificate](/configuration/certificate/#store). + +**5**: + +See [DNS-01 Challenge](/configuration/shared/dns01_challenge/). + +**6**: + +sing-box can now monitor Wi-Fi state on Linux and Windows to enable routing rules based on `wifi_ssid` and `wifi_bssid`. + +See [Wi-Fi State](/configuration/shared/wifi-state/). + +**7**: + +See [TLS](/configuration/shared/tls/). + +**8**: + +Adds `kernel_tx` and `kernel_rx` options for TLS inbound. +Enables kernel-level TLS offloading via `splice(2)` on Linux 5.1+ with TLS 1.3. + +See [TLS](/configuration/shared/tls/). + +**9**: + +sing-box can now proxy ICMP echo (ping) requests. +A new `icmp` network type is available for route rules. +Supported from TUN, WireGuard and Tailscale inbounds to Direct, WireGuard and Tailscale outbounds. +The `reject` action can also reply to ICMP echo requests. + +**10**: + +New rule items for matching based on interface IP addresses, available in route rules, DNS rules and rule-sets. + +**11**: + +Matches outbounds' preferred routes. +For Tailscale: MagicDNS domains and peers' allowed IPs. For WireGuard: peers' allowed IPs. + +**12**: + +The `local` DNS server now uses platform-native resolution: +`getaddrinfo`/libresolv on Apple platforms, systemd-resolved DBus on Linux. +A new `prefer_go` option is available to opt out. + +See [Local DNS](/configuration/dns/server/local/). + +**13**: + +The default TCP keep-alive initial period has been updated from 10 minutes to 5 minutes. + +See [Dial Fields](/configuration/shared/dial/#tcp_keep_alive). + +**14**: + +Adds the Linux socket option `IP_BIND_ADDRESS_NO_PORT` support when explicitly binding to a source address. + +This allows reusing the same source port for multiple connections, improving scalability for high-concurrency proxy scenarios. + +See [Dial Fields](/configuration/shared/dial/#bind_address_no_port). + +**15**: + +Tailscale endpoint can now create a system TUN interface to handle traffic directly. +New `relay_server_port` and `relay_server_static_endpoints` options for incoming relay connections. +New `advertise_tags` option for ACL tag advertisement. + +See [Tailscale endpoint](/configuration/endpoint/tailscale/). + +**16**: + +CCM (Claude Code Multiplexer) service allows you to access your local Claude Code subscription remotely through custom tokens, eliminating the need for OAuth authentication on remote clients. + +See [CCM](/configuration/service/ccm). + +**17**: + +See [OCM](/configuration/service/ocm). + +**18**: + +Due to maintenance difficulties, sing-box 1.13.0 requires at least Go 1.24 to compile. + +**19**: + +Due to maintenance difficulties, sing-box 1.13.0 will be the last version to support Android 5.0, +and only through a separate legacy build (with `-legacy-android-5` suffix). + +For standalone binaries, the minimum Android version has been raised to Android 6.0, +since Termux requires Android 7.0 or later. + +**20**: + +This update fixes missing padding extension for Chrome 120+ fingerprints. + +Also, documentation has been updated with a warning about uTLS fingerprinting vulnerabilities. +uTLS is not recommended for censorship circumvention due to fundamental architectural limitations; +use NaiveProxy instead for TLS fingerprint resistance. + +#### 1.12.23 + +* Fixes and improvements + +#### 1.13.0-rc.5 + +* Add `mipsle`, `mips64le`, `riscv64` and `loong64` support for NaiveProxy outbound + +#### 1.12.22 + +* Fixes and improvements + +#### 1.13.0-rc.3 + +* Fixes and improvements + +#### 1.12.21 + +* Fixes and improvements + +#### 1.13.0-rc.2 + +* Fixes and improvements + +#### 1.12.20 + +* Fixes and improvements + +#### 1.13.0-rc.1 + +* Fixes and improvements + +#### 1.12.19 + +* Fixes and improvements + +#### 1.13.0-beta.8 + +* Add fallback routing rule for `auto_redirect` **1** +* Fixes and improvements + +**1**: + +Adds a fallback iproute2 rule checked after system default rules (32766: main, 32767: default), +ensuring traffic is routed to the sing-box table when no route is found in system tables. + +The rule index can be customized via `auto_redirect_iproute2_fallback_rule_index` (default: 32768). + +#### 1.12.18 + +* Add fallback routing rule for `auto_redirect` **1** +* Fixes and improvements + +**1**: + +Adds a fallback iproute2 rule checked after system default rules (32766: main, 32767: default), +ensuring traffic is routed to the sing-box table when no route is found in system tables. + +The rule index can be customized via `auto_redirect_iproute2_fallback_rule_index` (default: 32768). + +#### 1.13.0-beta.6 + +* Update uTLS to v1.8.2 **1** +* Fixes and improvements + +**1**: + +This update fixes missing padding extension for Chrome 120+ fingerprints. + +Also, documentation has been updated with a warning about uTLS fingerprinting vulnerabilities. +uTLS is not recommended for censorship circumvention due to fundamental architectural limitations; +use NaiveProxy instead for TLS fingerprint resistance. + +#### 1.12.17 + +* Update uTLS to v1.8.2 **1** +* Fixes and improvements + +**1**: + +This update fixes missing padding extension for Chrome 120+ fingerprints. + +Also, documentation has been updated with a warning about uTLS fingerprinting vulnerabilities. +uTLS is not recommended for censorship circumvention due to fundamental architectural limitations; +use NaiveProxy instead for TLS fingerprint resistance. + +#### 1.13.0-beta.5 + +* Fixes and improvements + +#### 1.12.16 + +* Fixes and improvements + +#### 1.13.0-beta.4 + +* Apple/Android: Add support for sharing configurations via [QRS](https://github.com/qifi-dev/qrs) +* Android: Add support for resisting VPN detection via Xposed +* Update quic-go to v0.59.0 +* Fixes and improvements + +#### 1.13.0-beta.2 + +* Add `bind_address_no_port` option for dial fields **1** +* Fixes and improvements + +**1**: + +Adds the Linux socket option `IP_BIND_ADDRESS_NO_PORT` support when explicitly binding to a source address. + +This allows reusing the same source port for multiple connections, improving scalability for high-concurrency proxy scenarios. + +See [Dial Fields](/configuration/shared/dial/#bind_address_no_port). + +#### 1.13.0-beta.1 + +* Add system interface support for Tailscale endpoint **1** +* Fixes and improvements + +**1**: + +Tailscale endpoint can now create a system TUN interface to handle traffic directly. + +See [Tailscale endpoint](/configuration/endpoint/tailscale/#system_interface). + +#### 1.12.15 + +* Fixes and improvements + +#### 1.13.0-alpha.36 + +* Downgrade quic-go to v0.57.1 +* Fixes and improvements + +#### 1.13.0-alpha.35 + +* Add pre-match support for `auto_redirect` **1** +* Fixes and improvements + +**1**: + +`auto_redirect` now allows you to bypass sing-box for connections based on routing rules. + +A new rule action `bypass` is introduced to support this feature. When matched during pre-match, the connection will bypass sing-box and connect directly. + +This feature requires Linux with `auto_redirect` enabled. + +See [Pre-match](/configuration/shared/pre-match/) and [Rule Action](/configuration/route/rule_action/#bypass). + +#### 1.13.0-alpha.34 + +* Add Chrome Root Store certificate option **1** +* Add new options for ACME DNS-01 challenge providers **2** +* Add Wi-Fi state support for Linux and Windows **3** +* Update naiveproxy to 143.0.7499.109 +* Update quic-go to v0.58.0 +* Update tailscale to v1.92.4 +* Drop support for go1.23 **4** +* Drop support for Android 5.0 **5** + +**1**: + +Adds `chrome` as a new certificate store option alongside `mozilla`. +Both stores filter out China-based CA certificates. + +See [Certificate](/configuration/certificate/#store). + +**2**: + +See [DNS-01 Challenge](/configuration/shared/dns01_challenge/). + +**3**: + +sing-box can now monitor Wi-Fi state on Linux and Windows to enable routing rules based on `wifi_ssid` and `wifi_bssid`. + +See [Wi-Fi State](/configuration/shared/wifi-state/). + +**4**: + +Due to maintenance difficulties, sing-box 1.13.0 requires at least Go 1.24 to compile. + +**5**: + +Due to maintenance difficulties, sing-box 1.13.0 will be the last version to support Android 5.0, +and only through a separate legacy build (with `-legacy-android-5` suffix). + +For standalone binaries, the minimum Android version has been raised to Android 6.0, +since Termux requires Android 7.0 or later. + +#### 1.12.14 + +* Fixes and improvements + +#### 1.13.0-alpha.33 + +* Fixes and improvements + +#### 1.13.0-alpha.32 + +* Remove `certificate_public_key_sha256` option for NaiveProxy outbound **1** +* Fixes and improvements + +**1**: + +Self-signed certificates change traffic behavior significantly, which defeats the purpose of NaiveProxy's design to resist traffic analysis. +For this reason, and due to maintenance costs, there is no reason to continue supporting `certificate_public_key_sha256`, which was designed to simplify the use of self-signed certificates. + +#### 1.13.0-alpha.31 + +* Add QUIC support for NaiveProxy outbound **1** +* Add QUIC congestion control option for NaiveProxy **2** +* Fixes and improvements + +**1**: + +NaiveProxy outbound now supports QUIC. + +See [NaiveProxy outbound](/configuration/outbound/naive/#quic). + +**2**: + +NaiveProxy inbound and outbound now supports configurable QUIC congestion control algorithms, including BBR and BBRv2. + +See [NaiveProxy inbound](/configuration/inbound/naive/#quic_congestion_control) and [NaiveProxy outbound](/configuration/outbound/naive/#quic_congestion_control). + +#### 1.13.0-alpha.30 + +* Add ECH support for NaiveProxy outbound **1** +* Add `tls.ech.query_server_name` option **2** +* Fix NaiveProxy outbound on Windows **3** +* Add OpenAI Codex Multiplexer service **4** +* Fixes and improvements + +**1**: + +See [NaiveProxy outbound](/configuration/outbound/naive/#tls). + +**2**: + +See [TLS](/configuration/shared/tls/#query_server_name). + +**3**: + +Each Windows release now includes `libcronet.dll`. +Ensure this file is in the same directory as `sing-box.exe` or in a directory listed in `PATH`. + +**4**: + +See [OCM](/configuration/service/ocm). + +#### 1.13.0-alpha.29 + +* Add UDP over TCP support for naiveproxy outbound **1** +* Fixes and improvements + +**1**: + +See [NaiveProxy outbound](/configuration/outbound/naive/#udp_over_tcp). + +#### 1.13.0-alpha.28 + +* Add naiveproxy outbound **1** +* Add `disable_tcp_keep_alive`, `tcp_keep_alive` and `tcp_keep_alive_interval` options for dial fields **2** +* Update default TCP keep-alive initial period from 10 minutes to 5 minutes +* Update quic-go to v0.57.1 +* Fixes and improvements + +**1**: + +Only available on Apple platforms, Android, Windows and some Linux architectures. + +See [NaiveProxy outbound](/configuration/outbound/naive/). + +**2**: + +See [Dial Fields](/configuration/shared/dial/#tcp_keep_alive). + +* __Unfortunately, for non-technical reasons, we are currently unable to notarize the standalone version of the macOS client: +because system extensions require signatures to function, we have had to temporarily halt its release.__ + +__We plan to fix the App Store release issue and launch a new standalone desktop client, but until then, +only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).__ + + +#### 1.12.13 + +* Fix naive inbound +* Fixes and improvements + +__Unfortunately, for non-technical reasons, we are currently unable to notarize the standalone version of the macOS client: +because system extensions require signatures to function, we have had to temporarily halt its release.__ + +__We plan to fix the App Store release issue and launch a new standalone desktop client, but until then, +only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).__ + +#### 1.12.12 + +* Fixes and improvements + +#### 1.13.0-alpha.26 + +* Update quic-go to v0.55.0 +* Fix memory leak in hysteria2 +* Fixes and improvements + +#### 1.12.11 + +* Fixes and improvements + +#### 1.13.0-alpha.24 + +* Add Claude Code Multiplexer service **1** +* Fixes and improvements + +**1**: + +CCM (Claude Code Multiplexer) service allows you to access your local Claude Code subscription remotely through custom tokens, eliminating the need for OAuth authentication on remote clients. + +See [CCM](/configuration/service/ccm). + +#### 1.13.0-alpha.23 + +* Fix compatibility with MPTCP **1** +* Fixes and improvements + +**1**: + +`auto_redirect` now rejects MPTCP connections by default to fix compatibility issues, +but you can change it to bypass the sing-box via the new `exclude_mptcp` option. + +See [TUN](/configuration/inbound/tun/#exclude_mptcp). + +#### 1.13.0-alpha.22 + +* Update uTLS to v1.8.1 **1** +* Fixes and improvements + +**1**: + +This update fixes an critical issue that could cause simulated Chrome fingerprints to be detected, +see https://github.com/refraction-networking/utls/pull/375. + +#### 1.12.10 + +* Update uTLS to v1.8.1 **1** +* Fixes and improvements + +**1**: + +This update fixes an critical issue that could cause simulated Chrome fingerprints to be detected, +see https://github.com/refraction-networking/utls/pull/375. + +#### 1.13.0-alpha.21 + +* Fix missing mTLS support in client options **1** +* Fixes and improvements + +See [TLS](/configuration/shared/tls/). + +#### 1.12.9 + +* Fixes and improvements + +#### 1.13.0-alpha.16 + +* Add curve preferences, pinned public key SHA256 and mTLS for TLS options **1** +* Fixes and improvements + +See [TLS](/configuration/shared/tls/). + +#### 1.13.0-alpha.15 + +* Update quic-go to v0.54.0 +* Update gVisor to v20250811 +* Update Tailscale to v1.86.5 +* Fixes and improvements + +#### 1.12.8 + +* Fixes and improvements + +#### 1.13.0-alpha.11 + +* Fixes and improvements + +#### 1.12.5 + +* Fixes and improvements + +#### 1.13.0-alpha.10 + +* Improve kTLS support **1** +* Fixes and improvements + +**1**: + +kTLS is now compatible with custom TLS implementations other than uTLS. + +#### 1.12.4 + +* Fixes and improvements + +#### 1.12.3 + +* Fixes and improvements + +#### 1.12.2 + +* Fixes and improvements + +#### 1.12.1 + +* Fixes and improvements + +#### 1.12.0 + +* Refactor DNS servers **1** +* Add domain resolver options**2** +* Add TLS fragment/record fragment support to route options and outbound TLS options **3** +* Add certificate options **4** +* Add Tailscale endpoint and DNS server **5** +* Drop support for go1.22 **6** +* Add AnyTLS protocol **7** +* Migrate to stdlib ECH implementation **8** +* Add NTP sniffer **9** +* Add wildcard SNI support for ShadowTLS inbound **10** +* Improve `auto_redirect` **11** +* Add control options for listeners **12** +* Add DERP service **13** +* Add Resolved service and DNS server **14** +* Add SSM API service **15** +* Add loopback address support for tun **16** +* Improve tun performance on Apple platforms **17** +* Update quic-go to v0.52.0 +* Update gVisor to 20250319.0 +* Update the status of graphical clients in stores **18** + +**1**: + +DNS servers are refactored for better performance and scalability. + +See [DNS server](/configuration/dns/server/). + +For migration, see [Migrate to new DNS server formats](/migration/#migrate-to-new-dns-server-formats). + +Compatibility for old formats will be removed in sing-box 1.14.0. + +**2**: + +Legacy `outbound` DNS rules are deprecated +and can be replaced by the new `domain_resolver` option. + +See [Dial Fields](/configuration/shared/dial/#domain_resolver) and +[Route](/configuration/route/#default_domain_resolver). + +For migration, +see [Migrate outbound DNS rule items to domain resolver](/migration/#migrate-outbound-dns-rule-items-to-domain-resolver). + +**3**: + +See [Route Action](/configuration/route/rule_action/#tls_fragment) and [TLS](/configuration/shared/tls/). + +**4**: + +New certificate options allow you to manage the default list of trusted X509 CA certificates. + +For the system certificate list, fixed Go not reading Android trusted certificates correctly. + +You can also use the Mozilla Included List instead, or add trusted certificates yourself. + +See [Certificate](/configuration/certificate/). + +**5**: + +See [Tailscale](/configuration/endpoint/tailscale/). + +**6**: + +Due to maintenance difficulties, sing-box 1.12.0 requires at least Go 1.23 to compile. + +For Windows 7 users, legacy binaries now continue to compile with Go 1.23 and patches +from [MetaCubeX/go](https://github.com/MetaCubeX/go). + +**7**: + +The new AnyTLS protocol claims to mitigate TLS proxy traffic characteristics and comes with a new multiplexing scheme. + +See [AnyTLS Inbound](/configuration/inbound/anytls/) and [AnyTLS Outbound](/configuration/outbound/anytls/). + +**8**: + +See [TLS](/configuration/shared/tls). + +The build tag `with_ech` is no longer needed and has been removed. + +**9**: + +See [Protocol Sniff](/configuration/route/sniff/). + +**10**: + +See [ShadowTLS](/configuration/inbound/shadowtls/#wildcard_sni). + +**11**: + +Now `auto_redirect` fixes compatibility issues between tun and Docker bridge networks, +see [Tun](/configuration/inbound/tun/#auto_redirect). + +**12**: + +You can now set `bind_interface`, `routing_mark` and `reuse_addr` in Listen Fields. + +See [Listen Fields](/configuration/shared/listen/). + +**13**: + +DERP service is a Tailscale DERP server, similar to [derper](https://pkg.go.dev/tailscale.com/cmd/derper). + +See [DERP Service](/configuration/service/derp/). + +**14**: + +Resolved service is a fake systemd-resolved DBUS service to receive DNS settings from other programs +(e.g. NetworkManager) and provide DNS resolution. + +See [Resolved Service](/configuration/service/resolved/) and [Resolved DNS Server](/configuration/dns/server/resolved/). + +**15**: + +SSM API service is a RESTful API server for managing Shadowsocks servers. + +See [SSM API Service](/configuration/service/ssm-api/). + +**16**: + +TUN now implements SideStore's StosVPN. + +See [Tun](/configuration/inbound/tun/#loopback_address). + +**17**: + +We have significantly improved the performance of tun inbound on Apple platforms, especially in the gVisor stack. + +The following data was tested +using [tun_bench](https://github.com/SagerNet/sing-box/blob/dev-next/cmd/internal/tun_bench/main.go) on M4 MacBook pro. + +| Version | Stack | MTU | Upload | Download | +|-------------|--------|-------|--------|----------| +| 1.11.15 | gvisor | 1500 | 852M | 2.57G | +| 1.12.0-rc.4 | gvisor | 1500 | 2.90G | 4.68G | +| 1.11.15 | gvisor | 4064 | 2.31G | 6.34G | +| 1.12.0-rc.4 | gvisor | 4064 | 7.54G | 12.2G | +| 1.11.15 | gvisor | 65535 | 27.6G | 18.1G | +| 1.12.0-rc.4 | gvisor | 65535 | 39.8G | 34.7G | +| 1.11.15 | system | 1500 | 664M | 706M | +| 1.12.0-rc.4 | system | 1500 | 2.44G | 2.51G | +| 1.11.15 | system | 4064 | 1.88G | 1.94G | +| 1.12.0-rc.4 | system | 4064 | 6.45G | 6.27G | +| 1.11.15 | system | 65535 | 26.2G | 17.4G | +| 1.12.0-rc.4 | system | 65535 | 17.6G | 21.0G | + +**18**: + +We continue to experience issues updating our sing-box apps on the App Store and Play Store. +Until we rewrite and resubmit the apps, they are considered irrecoverable. +Therefore, after this release, we will not be repeating this notice unless there is new information. + +### 1.11.15 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.32 + +* Improve tun performance on Apple platforms **1** +* Fixes and improvements + +**1**: + +We have significantly improved the performance of tun inbound on Apple platforms, especially in the gVisor stack. + +### 1.11.14 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.24 + +* Allow `tls_fragment` and `tls_record_fragment` to be enabled together **1** +* Also add fragment options for TLS client configuration **2** +* Fixes and improvements + +**1**: + +For debugging only, it is recommended to disable if record fragmentation works. + +See [Route Action](/configuration/route/rule_action/#tls_fragment). + +**2**: + +See [TLS](/configuration/shared/tls/). + +#### 1.12.0-beta.23 + +* Add loopback address support for tun **1** +* Add cache support for ssm-api **2** +* Fixes and improvements + +**1**: + +TUN now implements SideStore's StosVPN. + +See [Tun](/configuration/inbound/tun/#loopback_address). + +**2**: + +See [SSM API Service](/configuration/service/ssm-api/#cache_path). + +#### 1.12.0-beta.21 + +* Fix missing `home` option for DERP service **1** +* Fixes and improvements + +**1**: + +You can now choose what the DERP home page shows, just like with derper's `-home` flag. + +See [DERP](/configuration/service/derp/#home). + +### 1.11.13 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.17 + +* Update quic-go to v0.52.0 +* Fixes and improvements + +#### 1.12.0-beta.15 + +* Add DERP service **1** +* Add Resolved service and DNS server **2** +* Add SSM API service **3** +* Fixes and improvements + +**1**: + +DERP service is a Tailscale DERP server, similar to [derper](https://pkg.go.dev/tailscale.com/cmd/derper). + +See [DERP Service](/configuration/service/derp/). + +**2**: + +Resolved service is a fake systemd-resolved DBUS service to receive DNS settings from other programs +(e.g. NetworkManager) and provide DNS resolution. + +See [Resolved Service](/configuration/service/resolved/) and [Resolved DNS Server](/configuration/dns/server/resolved/). + +**3**: + +SSM API service is a RESTful API server for managing Shadowsocks servers. + +See [SSM API Service](/configuration/service/ssm-api/). + +### 1.11.11 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.13 + +* Add TLS record fragment route options **1** +* Add missing `accept_routes` option for Tailscale **2** +* Fixes and improvements + +**1**: + +See [Route Action](/configuration/route/rule_action/#tls_record_fragment). + +**2**: + +See [Tailscale](/configuration/endpoint/tailscale/#accept_routes). + +#### 1.12.0-beta.10 + +* Add control options for listeners **1** +* Fixes and improvements + +**1**: + +You can now set `bind_interface`, `routing_mark` and `reuse_addr` in Listen Fields. + +See [Listen Fields](/configuration/shared/listen/). + +### 1.11.10 + +* Undeprecate the `block` outbound **1** +* Fixes and improvements + +**1**: + +Since we don’t have a replacement for using the `block` outbound in selectors yet, +we decided to temporarily undeprecate the `block` outbound until a replacement is available in the future. + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.9 + +* Update quic-go to v0.51.0 +* Fixes and improvements + +### 1.11.9 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.5 + +* Fixes and improvements + +### 1.11.8 + +* Improve `auto_redirect` **1** +* Fixes and improvements + +**1**: + +Now `auto_redirect` fixes compatibility issues between TUN and Docker bridge networks, +see [Tun](/configuration/inbound/tun/#auto_redirect). + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.3 + +* Fixes and improvements + +### 1.11.7 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-beta.1 + +* Fixes and improvements + +**1**: + +Now `auto_redirect` fixes compatibility issues between tun and Docker bridge networks, +see [Tun](/configuration/inbound/tun/#auto_redirect). + +### 1.11.6 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-alpha.19 + +* Update gVisor to 20250319.0 +* Fixes and improvements + +#### 1.12.0-alpha.18 + +* Add wildcard SNI support for ShadowTLS inbound **1** +* Fixes and improvements + +**1**: + +See [ShadowTLS](/configuration/inbound/shadowtls/#wildcard_sni). + +#### 1.12.0-alpha.17 + +* Add NTP sniffer **1** +* Fixes and improvements + +**1**: + +See [Protocol Sniff](/configuration/route/sniff/). + +#### 1.12.0-alpha.16 + +* Update `domain_resolver` behavior **1** +* Fixes and improvements + +**1**: + +`route.default_domain_resolver` or `outbound.domain_resolver` is now optional when only one DNS server is configured. + +See [Dial Fields](/configuration/shared/dial/#domain_resolver). + +### 1.11.5 + +* Fixes and improvements + +_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we +violated the rules (TestFlight users are not affected)._ + +#### 1.12.0-alpha.13 + +* Move `predefined` DNS server to DNS rule action **1** +* Fixes and improvements + +**1**: + +See [DNS Rule Action](/configuration/dns/rule_action/#predefined). + +### 1.11.4 + +* Fixes and improvements + +#### 1.12.0-alpha.11 + +* Fixes and improvements + +#### 1.12.0-alpha.10 + +* Add AnyTLS protocol **1** +* Improve `resolve` route action **2** +* Migrate to stdlib ECH implementation **3** +* Fixes and improvements + +**1**: + +The new AnyTLS protocol claims to mitigate TLS proxy traffic characteristics and comes with a new multiplexing scheme. + +See [AnyTLS Inbound](/configuration/inbound/anytls/) and [AnyTLS Outbound](/configuration/outbound/anytls/). + +**2**: + +`resolve` route action now accepts `disable_cache` and other options like in DNS route actions, +see [Route Action](/configuration/route/rule_action). + +**3**: + +See [TLS](/configuration/shared/tls). + +The build tag `with_ech` is no longer needed and has been removed. + +#### 1.12.0-alpha.7 + +* Add Tailscale DNS server **1** +* Fixes and improvements + +**1**: + +See [Tailscale](/configuration/dns/server/tailscale/). + +#### 1.12.0-alpha.6 + +* Add Tailscale endpoint **1** +* Drop support for go1.22 **2** +* Fixes and improvements + +**1**: + +See [Tailscale](/configuration/endpoint/tailscale/). + +**2**: + +Due to maintenance difficulties, sing-box 1.12.0 requires at least Go 1.23 to compile. + +For Windows 7 users, legacy binaries now continue to compile with Go 1.23 and patches +from [MetaCubeX/go](https://github.com/MetaCubeX/go). + +### 1.11.3 + +* Fixes and improvements + +_This version overwrites 1.11.2, as incorrect binaries were released due to a bug in the continuous integration +process._ + +#### 1.12.0-alpha.5 + +* Fixes and improvements + +### 1.11.1 + +* Fixes and improvements + +#### 1.12.0-alpha.2 + +* Update quic-go to v0.49.0 +* Fixes and improvements + +#### 1.12.0-alpha.1 + +* Refactor DNS servers **1** +* Add domain resolver options**2** +* Add TLS fragment route options **3** +* Add certificate options **4** + +**1**: + +DNS servers are refactored for better performance and scalability. + +See [DNS server](/configuration/dns/server/). + +For migration, see [Migrate to new DNS server formats](/migration/#migrate-to-new-dns-server-formats). + +Compatibility for old formats will be removed in sing-box 1.14.0. + +**2**: + +Legacy `outbound` DNS rules are deprecated +and can be replaced by the new `domain_resolver` option. + +See [Dial Fields](/configuration/shared/dial/#domain_resolver) and +[Route](/configuration/route/#default_domain_resolver). + +For migration, +see [Migrate outbound DNS rule items to domain resolver](/migration/#migrate-outbound-dns-rule-items-to-domain-resolver). + +**3**: + +The new TLS fragment route options allow you to fragment TLS handshakes to bypass firewalls. + +This feature is intended to circumvent simple firewalls based on **plaintext packet matching**, and should not be used +to circumvent real censorship. + +Since it is not designed for performance, it should not be applied to all connections, but only to server names that are +known to be blocked. + +See [Route Action](/configuration/route/rule_action/#tls_fragment). + +**4**: + +New certificate options allow you to manage the default list of trusted X509 CA certificates. + +For the system certificate list, fixed Go not reading Android trusted certificates correctly. + +You can also use the Mozilla Included List instead, or add trusted certificates yourself. + +See [Certificate](/configuration/certificate/). + +### 1.11.0 + +Important changes since 1.10: + +* Introducing rule actions **1** +* Improve tun compatibility **3** +* Merge route options to route actions **4** +* Add `network_type`, `network_is_expensive` and `network_is_constrainted` rule items **5** +* Add multi network dialing **6** +* Add `cache_capacity` DNS option **7** +* Add `override_address` and `override_port` route options **8** +* Upgrade WireGuard outbound to endpoint **9** +* Add UDP GSO support for WireGuard +* Make GSO adaptive **10** +* Add UDP timeout route option **11** +* Add more masquerade options for hysteria2 **12** +* Add `rule-set merge` command +* Add port hopping support for Hysteria2 **13** +* Hysteria2 `ignore_client_bandwidth` behavior update **14** + +**1**: + +New rule actions replace legacy inbound fields and special outbound fields, +and can be used for pre-matching **2**. + +See [Rule](/configuration/route/rule/), +[Rule Action](/configuration/route/rule_action/), +[DNS Rule](/configuration/dns/rule/) and +[DNS Rule Action](/configuration/dns/rule_action/). + +For migration, see +[Migrate legacy special outbounds to rule actions](/migration/#migrate-legacy-special-outbounds-to-rule-actions), +[Migrate legacy inbound fields to rule actions](/migration/#migrate-legacy-inbound-fields-to-rule-actions) +and [Migrate legacy DNS route options to rule actions](/migration/#migrate-legacy-dns-route-options-to-rule-actions). + +**2**: + +Similar to Surge's pre-matching. + +Specifically, new rule actions allow you to reject connections with +TCP RST (for TCP connections) and ICMP port unreachable (for UDP packets) +before connection established to improve tun's compatibility. + +See [Rule Action](/configuration/route/rule_action/). + +**3**: + +When `gvisor` tun stack is enabled, even if the request passes routing, +if the outbound connection establishment fails, +the connection still does not need to be established and a TCP RST is replied. + +**4**: + +Route options in DNS route actions will no longer be considered deprecated, +see [DNS Route Action](/configuration/dns/rule_action/). + +Also, now `udp_disable_domain_unmapping` and `udp_connect` can also be configured in route action, +see [Route Action](/configuration/route/rule_action/). + +**5**: + +When using in graphical clients, new routing rule items allow you to match on +network type (WIFI, cellular, etc.), whether the network is expensive, and whether Low Data Mode is enabled. + +See [Route Rule](/configuration/route/rule/), [DNS Route Rule](/configuration/dns/rule/) +and [Headless Rule](/configuration/rule-set/headless-rule/). + +**6**: + +Similar to Surge's strategy. + +New options allow you to connect using multiple network interfaces, +prefer or only use one type of interface, +and configure a timeout to fallback to other interfaces. + +See [Dial Fields](/configuration/shared/dial/#network_strategy), +[Rule Action](/configuration/route/rule_action/#network_strategy) +and [Route](/configuration/route/#default_network_strategy). + +**7**: + +See [DNS](/configuration/dns/#cache_capacity). + +**8**: + +See [Rule Action](/configuration/route/#override_address) and +[Migrate destination override fields to route options](/migration/#migrate-destination-override-fields-to-route-options). + +**9**: + +The new WireGuard endpoint combines inbound and outbound capabilities, +and the old outbound will be removed in sing-box 1.13.0. + +See [Endpoint](/configuration/endpoint/), [WireGuard Endpoint](/configuration/endpoint/wireguard/) +and [Migrate WireGuard outbound fields to route options](/migration/#migrate-wireguard-outbound-to-endpoint). + +**10**: + +For WireGuard outbound and endpoint, GSO will be automatically enabled when available, +see [WireGuard Outbound](/configuration/outbound/wireguard/#gso). + +For TUN, GSO has been removed, +see [Deprecated](/deprecated/#gso-option-in-tun). + +**11**: + +See [Rule Action](/configuration/route/rule_action/#udp_timeout). + +**12**: + +See [Hysteria2](/configuration/inbound/hysteria2/#masquerade). + +**13**: + +See [Hysteria2](/configuration/outbound/hysteria2/). + +**14**: + +When `up_mbps` and `down_mbps` are set, `ignore_client_bandwidth` instead denies clients from using BBR CC. + +### 1.10.7 + +* Fixes and improvements + +#### 1.11.0-beta.20 + +* Hysteria2 `ignore_client_bandwidth` behavior update **1** +* Fixes and improvements + +**1**: + +When `up_mbps` and `down_mbps` are set, `ignore_client_bandwidth` instead denies clients from using BBR CC. + +See [Hysteria2](/configuration/inbound/hysteria2/#ignore_client_bandwidth). + +#### 1.11.0-beta.17 + +* Add port hopping support for Hysteria2 **1** +* Fixes and improvements + +**1**: + +See [Hysteria2](/configuration/outbound/hysteria2/). + +#### 1.11.0-beta.14 + +* Allow adding route (exclude) address sets to routes **1** +* Fixes and improvements + +**1**: + +When `auto_redirect` is not enabled, directly add `route[_exclude]_address_set` +to tun routes (equivalent to `route[_exclude]_address`). + +Note that it **doesn't work on the Android graphical client** due to +the Android VpnService not being able to handle a large number of routes (DeadSystemException), +but otherwise it works fine on all command line clients and Apple platforms. + +See [route_address_set](/configuration/inbound/tun/#route_address_set) and +[route_exclude_address_set](/configuration/inbound/tun/#route_exclude_address_set). + +#### 1.11.0-beta.12 + +* Add `rule-set merge` command +* Fixes and improvements + +#### 1.11.0-beta.3 + +* Add more masquerade options for hysteria2 **1** +* Fixes and improvements + +**1**: + +See [Hysteria2](/configuration/inbound/hysteria2/#masquerade). + +#### 1.11.0-alpha.25 + +* Update quic-go to v0.48.2 +* Fixes and improvements + +#### 1.11.0-alpha.22 + +* Add UDP timeout route option **1** +* Fixes and improvements + +**1**: + +See [Rule Action](/configuration/route/rule_action/#udp_timeout). + +#### 1.11.0-alpha.20 + +* Add UDP GSO support for WireGuard +* Make GSO adaptive **1** + +**1**: + +For WireGuard outbound and endpoint, GSO will be automatically enabled when available, +see [WireGuard Outbound](/configuration/outbound/wireguard/#gso). + +For TUN, GSO has been removed, +see [Deprecated](/deprecated/#gso-option-in-tun). + +#### 1.11.0-alpha.19 + +* Upgrade WireGuard outbound to endpoint **1** +* Fixes and improvements + +**1**: + +The new WireGuard endpoint combines inbound and outbound capabilities, +and the old outbound will be removed in sing-box 1.13.0. + +See [Endpoint](/configuration/endpoint/), [WireGuard Endpoint](/configuration/endpoint/wireguard/) +and [Migrate WireGuard outbound fields to route options](/migration/#migrate-wireguard-outbound-to-endpoint). + +### 1.10.2 + +* Add deprecated warnings +* Fix proxying websocket connections in HTTP/mixed inbounds +* Fixes and improvements + +#### 1.11.0-alpha.18 + +* Fixes and improvements + +#### 1.11.0-alpha.16 + +* Add `cache_capacity` DNS option **1** +* Add `override_address` and `override_port` route options **2** +* Fixes and improvements + +**1**: + +See [DNS](/configuration/dns/#cache_capacity). + +**2**: + +See [Rule Action](/configuration/route/#override_address) and +[Migrate destination override fields to route options](/migration/#migrate-destination-override-fields-to-route-options). + +#### 1.11.0-alpha.15 + +* Improve multi network dialing **1** +* Fixes and improvements + +**1**: + +New options allow you to configure the network strategy flexibly. + +See [Dial Fields](/configuration/shared/dial/#network_strategy), +[Rule Action](/configuration/route/rule_action/#network_strategy) +and [Route](/configuration/route/#default_network_strategy). + +#### 1.11.0-alpha.14 + +* Add multi network dialing **1** +* Fixes and improvements + +**1**: + +Similar to Surge's strategy. + +New options allow you to connect using multiple network interfaces, +prefer or only use one type of interface, +and configure a timeout to fallback to other interfaces. + +See [Dial Fields](/configuration/shared/dial/#network_strategy), +[Rule Action](/configuration/route/rule_action/#network_strategy) +and [Route](/configuration/route/#default_network_strategy). + +#### 1.11.0-alpha.13 + +* Fixes and improvements + +#### 1.11.0-alpha.12 + +* Merge route options to route actions **1** +* Add `network_type`, `network_is_expensive` and `network_is_constrainted` rule items **2** +* Fixes and improvements + +**1**: + +Route options in DNS route actions will no longer be considered deprecated, +see [DNS Route Action](/configuration/dns/rule_action/). + +Also, now `udp_disable_domain_unmapping` and `udp_connect` can also be configured in route action, +see [Route Action](/configuration/route/rule_action/). + +**2**: + +When using in graphical clients, new routing rule items allow you to match on +network type (WIFI, cellular, etc.), whether the network is expensive, and whether Low Data Mode is enabled. + +See [Route Rule](/configuration/route/rule/), [DNS Route Rule](/configuration/dns/rule/) +and [Headless Rule](/configuration/rule-set/headless-rule/). + +#### 1.11.0-alpha.9 + +* Improve tun compatibility **1** +* Fixes and improvements + +**1**: + +When `gvisor` tun stack is enabled, even if the request passes routing, +if the outbound connection establishment fails, +the connection still does not need to be established and a TCP RST is replied. + +#### 1.11.0-alpha.7 + +* Introducing rule actions **1** + +**1**: + +New rule actions replace legacy inbound fields and special outbound fields, +and can be used for pre-matching **2**. + +See [Rule](/configuration/route/rule/), +[Rule Action](/configuration/route/rule_action/), +[DNS Rule](/configuration/dns/rule/) and +[DNS Rule Action](/configuration/dns/rule_action/). + +For migration, see +[Migrate legacy special outbounds to rule actions](/migration/#migrate-legacy-special-outbounds-to-rule-actions), +[Migrate legacy inbound fields to rule actions](/migration/#migrate-legacy-inbound-fields-to-rule-actions) +and [Migrate legacy DNS route options to rule actions](/migration/#migrate-legacy-dns-route-options-to-rule-actions). + +**2**: + +Similar to Surge's pre-matching. + +Specifically, new rule actions allow you to reject connections with +TCP RST (for TCP connections) and ICMP port unreachable (for UDP packets) +before connection established to improve tun's compatibility. + +See [Rule Action](/configuration/route/rule_action/). + +#### 1.11.0-alpha.6 + +* Update quic-go to v0.48.1 +* Set gateway for tun correctly +* Fixes and improvements + +#### 1.11.0-alpha.2 + +* Add warnings for usage of deprecated features +* Fixes and improvements + +#### 1.11.0-alpha.1 + +* Update quic-go to v0.48.0 +* Fixes and improvements + +### 1.10.1 + +* Fixes and improvements + +### 1.10.0 + +Important changes since 1.9: + +* Introducing auto-redirect **1** +* Add AdGuard DNS Filter support **2** +* TUN address fields are merged **3** +* Add custom options for `auto-route` and `auto-redirect` **4** +* Drop support for go1.18 and go1.19 **5** +* Add tailing comma support in JSON configuration +* Improve sniffers **6** +* Add new `inline` rule-set type **7** +* Add access control options for Clash API **8** +* Add `rule_set_ip_cidr_accept_empty` DNS address filter rule item **9** +* Add auto reload support for local rule-set +* Update fsnotify usages **10** +* Add IP address support for `rule-set match` command +* Add `rule-set decompile` command +* Add `process_path_regex` rule item +* Update uTLS to v1.6.7 **11** +* Optimize memory usages of rule-sets **12** + +**1**: + +The new auto-redirect feature allows TUN to automatically +configure connection redirection to improve proxy performance. + +When auto-redirect is enabled, new route address set options will allow you to +automatically configure destination IP CIDR rules from a specified rule set to the firewall. + +Specified or unspecified destinations will bypass the sing-box routes to get better performance +(for example, keep hardware offloading of direct traffics on the router). + +See [TUN](/configuration/inbound/tun). + +**2**: + +The new feature allows you to use AdGuard DNS Filter lists in a sing-box without AdGuard Home. + +See [AdGuard DNS Filter](/configuration/rule-set/adguard/). + +**3**: + +See [Migration](/migration/#tun-address-fields-are-merged). + +**4**: + +See [iproute2_table_index](/configuration/inbound/tun/#iproute2_table_index), +[iproute2_rule_index](/configuration/inbound/tun/#iproute2_rule_index), +[auto_redirect_input_mark](/configuration/inbound/tun/#auto_redirect_input_mark) and +[auto_redirect_output_mark](/configuration/inbound/tun/#auto_redirect_output_mark). + +**5**: + +Due to maintenance difficulties, sing-box 1.10.0 requires at least Go 1.20 to compile. + +**6**: + +BitTorrent, DTLS, RDP, SSH sniffers are added. + +Now the QUIC sniffer can correctly extract the server name from Chromium requests and +can identify common QUIC clients, including +Chromium, Safari, Firefox, quic-go (including uquic disguised as Chrome). + +**7**: + +The new [rule-set](/configuration/rule-set/) type inline (which also becomes the default type) +allows you to write headless rules directly without creating a rule-set file. + +**8**: + +With new access control options, not only can you allow Clash dashboards +to access the Clash API on your local network, +you can also manually limit the websites that can access the API instead of allowing everyone. + +See [Clash API](/configuration/experimental/clash-api/). + +**9**: + +See [DNS Rule](/configuration/dns/rule/#rule_set_ip_cidr_accept_empty). + +**10**: + +sing-box now uses fsnotify correctly and will not cancel watching +if the target file is deleted or recreated via rename (e.g. `mv`). + +This affects all path options that support reload, including +`tls.certificate_path`, `tls.key_path`, `tls.ech.key_path` and `rule_set.path`. + +**11**: + +Some legacy chrome fingerprints have been removed and will fallback to chrome, +see [utls](/configuration/shared/tls#utls). + +**12**: + +See [Source Format](/configuration/rule-set/source-format/#version). + +### 1.9.7 + +* Fixes and improvements + +#### 1.10.0-beta.11 + +* Update uTLS to v1.6.7 **1** + +**1**: + +Some legacy chrome fingerprints have been removed and will fallback to chrome, +see [utls](/configuration/shared/tls#utls). + +#### 1.10.0-beta.10 + +* Add `process_path_regex` rule item +* Fixes and improvements + +_The macOS standalone versions of sing-box (>=1.9.5/<1.10.0-beta.11) now silently fail and require manual granting of +the **Full Disk Access** permission to system extension to start, probably due to Apple's changed security policy. We +will prompt users about this in feature versions._ + +### 1.9.6 + +* Fixes and improvements + +### 1.9.5 + +* Update quic-go to v0.47.0 +* Fix direct dialer not resolving domain +* Fix no error return when empty DNS cache retrieved +* Fix build with go1.23 +* Fix stream sniffer +* Fix bad redirect in clash-api +* Fix wireguard events chan leak +* Fix cached conn eats up read deadlines +* Fix disconnected interface selected as default in windows +* Update Bundle Identifiers for Apple platform clients **1** + +**1**: + +See [Migration](/migration/#bundle-identifier-updates-in-apple-platform-clients). + +We are still working on getting all sing-box apps back on the App Store, which should be completed within a week +(SFI on the App Store and others on TestFlight are already available). + +#### 1.10.0-beta.8 + +* Fixes and improvements + +_With the help of a netizen, we are in the process of getting sing-box apps back on the App Store, which should be +completed within a month (TestFlight is already available)._ + +#### 1.10.0-beta.7 + +* Update quic-go to v0.47.0 +* Fixes and improvements + +#### 1.10.0-beta.6 + +* Add RDP sniffer +* Fixes and improvements + +#### 1.10.0-beta.5 + +* Add PNA support for [Clash API](/configuration/experimental/clash-api/) +* Fixes and improvements + +#### 1.10.0-beta.3 + +* Add SSH sniffer +* Fixes and improvements + +#### 1.10.0-beta.2 + +* Build with go1.23 +* Fixes and improvements + +### 1.9.4 + +* Update quic-go to v0.46.0 +* Update Hysteria2 BBR congestion control +* Filter HTTPS ipv4hint/ipv6hint with domain strategy +* Fix crash on Android when using process rules +* Fix non-IP queries accepted by address filter rules +* Fix UDP server for shadowsocks AEAD multi-user inbounds +* Fix default next protos for v2ray QUIC transport +* Fix default end value of port range configuration options +* Fix reset v2ray transports +* Fix panic caused by rule-set generation of duplicate keys for `domain_suffix` +* Fix UDP connnection leak when sniffing +* Fixes and improvements + +_Due to problems with our Apple developer account, +sing-box apps on Apple platforms are temporarily unavailable for download or update. +If your company or organization is willing to help us return to the App Store, +please [contact us](mailto:contact@sagernet.org)._ + +#### 1.10.0-alpha.29 + +* Update quic-go to v0.46.0 +* Fixes and improvements + +#### 1.10.0-alpha.25 + +* Add AdGuard DNS Filter support **1** + +**1**: + +The new feature allows you to use AdGuard DNS Filter lists in a sing-box without AdGuard Home. + +See [AdGuard DNS Filter](/configuration/rule-set/adguard/). + +#### 1.10.0-alpha.23 + +* Add Chromium support for QUIC sniffer +* Add client type detect support for QUIC sniffer **1** +* Fixes and improvements + +**1**: + +Now the QUIC sniffer can correctly extract the server name from Chromium requests and +can identify common QUIC clients, including +Chromium, Safari, Firefox, quic-go (including uquic disguised as Chrome). + +See [Protocol Sniff](/configuration/route/sniff/) and [Route Rule](/configuration/route/rule/#client). + +#### 1.10.0-alpha.22 + +* Optimize memory usages of rule-sets **1** +* Fixes and improvements + +**1**: + +See [Source Format](/configuration/rule-set/source-format/#version). + +#### 1.10.0-alpha.20 + +* Add DTLS sniffer +* Fixes and improvements + +#### 1.10.0-alpha.19 + +* Add `rule-set decompile` command +* Add IP address support for `rule-set match` command +* Fixes and improvements + +#### 1.10.0-alpha.18 + +* Add new `inline` rule-set type **1** +* Add auto reload support for local rule-set +* Update fsnotify usages **2** +* Fixes and improvements + +**1**: + +The new [rule-set](/configuration/rule-set/) type inline (which also becomes the default type) +allows you to write headless rules directly without creating a rule-set file. + +**2**: + +sing-box now uses fsnotify correctly and will not cancel watching +if the target file is deleted or recreated via rename (e.g. `mv`). + +This affects all path options that support reload, including +`tls.certificate_path`, `tls.key_path`, `tls.ech.key_path` and `rule_set.path`. + +#### 1.10.0-alpha.17 + +* Some chaotic changes **1** +* `rule_set_ipcidr_match_source` rule items are renamed **2** +* Add `rule_set_ip_cidr_accept_empty` DNS address filter rule item **3** +* Update quic-go to v0.45.1 +* Fixes and improvements + +**1**: + +Something may be broken, please actively report problems with this version. + +**2**: + +`rule_set_ipcidr_match_source` route and DNS rule items are renamed to +`rule_set_ip_cidr_match_source` and will be remove in sing-box 1.11.0. + +**3**: + +See [DNS Rule](/configuration/dns/rule/#rule_set_ip_cidr_accept_empty). + +#### 1.10.0-alpha.16 + +* Add custom options for `auto-route` and `auto-redirect` **1** +* Fixes and improvements + +**1**: + +See [iproute2_table_index](/configuration/inbound/tun/#iproute2_table_index), +[iproute2_rule_index](/configuration/inbound/tun/#iproute2_rule_index), +[auto_redirect_input_mark](/configuration/inbound/tun/#auto_redirect_input_mark) and +[auto_redirect_output_mark](/configuration/inbound/tun/#auto_redirect_output_mark). + +#### 1.10.0-alpha.13 + +* TUN address fields are merged **1** +* Add route address set support for auto-redirect **2** + +**1**: + +See [Migration](/migration/#tun-address-fields-are-merged). + +**2**: + +The new feature will allow you to configure the destination IP CIDR rules +in the specified rule-sets to the firewall automatically. + +Specified or unspecified destinations will bypass the sing-box routes to get better performance +(for example, keep hardware offloading of direct traffics on the router). + +See [route_address_set](/configuration/inbound/tun/#route_address_set) +and [route_exclude_address_set](/configuration/inbound/tun/#route_exclude_address_set). + +#### 1.10.0-alpha.12 + +* Fix auto-redirect not configuring nftables forward chain correctly +* Fixes and improvements + +### 1.9.3 + +* Fixes and improvements + +#### 1.10.0-alpha.10 + +* Fixes and improvements + +### 1.9.2 + +* Fixes and improvements + +#### 1.10.0-alpha.8 + +* Drop support for go1.18 and go1.19 **1** +* Update quic-go to v0.45.0 +* Update Hysteria2 BBR congestion control +* Fixes and improvements + +**1**: + +Due to maintenance difficulties, sing-box 1.10.0 requires at least Go 1.20 to compile. + +### 1.9.1 + +* Fixes and improvements + +#### 1.10.0-alpha.7 + +* Fixes and improvements + +#### 1.10.0-alpha.5 + +* Improve auto-redirect **1** + +**1**: + +nftables support and DNS hijacking has been added. + +Tun inbounds with `auto_route` and `auto_redirect` now works as expected on routers **without intervention**. + +#### 1.10.0-alpha.4 + +* Fix auto-redirect **1** +* Improve auto-route on linux **2** + +**1**: + +Tun inbounds with `auto_route` and `auto_redirect` now works as expected on routers. + +**2**: + +Tun inbounds with `auto_route` and `strict_route` now works as expected on routers and servers, +but the usages of [exclude_interface](/configuration/inbound/tun/#exclude_interface) need to be updated. + +#### 1.10.0-alpha.2 + +* Move auto-redirect to Tun **1** +* Fixes and improvements + +**1**: + +Linux support are added. + +See [Tun](/configuration/inbound/tun/#auto_redirect). + +#### 1.10.0-alpha.1 + +* Add tailing comma support in JSON configuration +* Add simple auto-redirect for Android **1** +* Add BitTorrent sniffer **2** + +**1**: + +It allows you to use redirect inbound in the sing-box Android client +and automatically configures IPv4 TCP redirection via su. + +This may alleviate the symptoms of some OCD patients who think that +redirect can effectively save power compared to the system HTTP Proxy. + +See [Redirect](/configuration/inbound/redirect/). + +**2**: + +See [Protocol Sniff](/configuration/route/sniff/). + +### 1.9.0 + +* Fixes and improvements + +Important changes since 1.8: + +* `domain_suffix` behavior update **1** +* `process_path` format update on Windows **2** +* Add address filter DNS rule items **3** +* Add support for `client-subnet` DNS options **4** +* Add rejected DNS response cache support **5** +* Add `bypass_domain` and `search_domain` platform HTTP proxy options **6** +* Fix missing `rule_set_ipcidr_match_source` item in DNS rules **7** +* Handle Windows power events +* Always disable cache for fake-ip DNS transport if `dns.independent_cache` disabled +* Improve DNS truncate behavior +* Update Hysteria protocol +* Update quic-go to v0.43.1 +* Update gVisor to 20240422.0 +* Mitigating TunnelVision attacks **8** + +**1**: + +See [Migration](/migration/#domain_suffix-behavior-update). + +**2**: + +See [Migration](/migration/#process_path-format-update-on-windows). + +**3**: + +The new DNS feature allows you to more precisely bypass Chinese websites via **DNS leaks**. Do not use plain local DNS +if using this method. + +See [Legacy Address Filter Fields](/configuration/dns/rule#legacy-address-filter-fields). + +[Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users) updated. + +**4**: + +See [DNS](/configuration/dns), [DNS Server](/configuration/dns/server) and [DNS Rules](/configuration/dns/rule). + +Since this feature makes the scenario mentioned in `alpha.1` no longer leak DNS requests, +the [Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users) has been updated. + +**5**: + +The new feature allows you to cache the check results of +[Legacy Address Filter Fields](/configuration/dns/rule/#legacy-address-filter-fields) until expiration. + +**6**: + +See [TUN](/configuration/inbound/tun) inbound. + +**7**: + +See [DNS Rule](/configuration/dns/rule/). + +**8**: + +See [TunnelVision](/manual/misc/tunnelvision). + +#### 1.9.0-rc.22 + +* Fixes and improvements + +#### 1.9.0-rc.20 + +* Prioritize `*_route_address` in linux auto-route +* Fix `*_route_address` in darwin auto-route + +#### 1.8.14 + +* Fix hysteria2 panic +* Fixes and improvements + +#### 1.9.0-rc.18 + +* Add custom prefix support in EDNS0 client subnet options +* Fix hysteria2 crash +* Fix `store_rdrc` corrupted +* Update quic-go to v0.43.1 +* Fixes and improvements + +#### 1.9.0-rc.16 + +* Mitigating TunnelVision attacks **1** +* Fixes and improvements + +**1**: + +See [TunnelVision](/manual/misc/tunnelvision). + +#### 1.9.0-rc.15 + +* Fixes and improvements + +#### 1.8.13 + +* Fix fake-ip mapping +* Fixes and improvements + +#### 1.9.0-rc.14 + +* Fixes and improvements + +#### 1.9.0-rc.13 + +* Update Hysteria protocol +* Update quic-go to v0.43.0 +* Update gVisor to 20240422.0 +* Fixes and improvements + +#### 1.8.12 + +* Now we have official APT and DNF repositories **1** +* Fix packet MTU for QUIC protocols +* Fixes and improvements + +**1**: + +Including stable and beta versions, see https://sing-box.sagernet.org/installation/package-manager/ + +#### 1.9.0-rc.11 + +* Fixes and improvements + +#### 1.8.11 + +* Fixes and improvements + +#### 1.8.10 + +* Fixes and improvements + +#### 1.9.0-beta.17 + +* Update `quic-go` to v0.42.0 +* Fixes and improvements + +#### 1.9.0-beta.16 + +* Fixes and improvements + +_Our Testflight distribution has been temporarily blocked by Apple (possibly due to too many beta versions) +and you cannot join the test, install or update the sing-box beta app right now. +Please wait patiently for processing._ + +#### 1.9.0-beta.14 + +* Update gVisor to 20240212.0-65-g71212d503 +* Fixes and improvements + +#### 1.8.9 + +* Fixes and improvements + +#### 1.8.8 + +* Fixes and improvements + +#### 1.9.0-beta.7 + +* Fixes and improvements + +#### 1.9.0-beta.6 + +* Fix address filter DNS rule items **1** +* Fix DNS outbound responding with wrong data +* Fixes and improvements + +**1**: + +Fixed an issue where address filter DNS rule was incorrectly rejected under certain circumstances. +If you have enabled `store_rdrc` to save results, consider clearing the cache file. + +#### 1.8.7 + +* Fixes and improvements + +#### 1.9.0-alpha.15 + +* Fixes and improvements + +#### 1.9.0-alpha.14 + +* Improve DNS truncate behavior +* Fixes and improvements + +#### 1.9.0-alpha.13 + +* Fixes and improvements + +#### 1.8.6 + +* Fixes and improvements + +#### 1.9.0-alpha.12 + +* Handle Windows power events +* Always disable cache for fake-ip DNS transport if `dns.independent_cache` disabled +* Fixes and improvements + +#### 1.9.0-alpha.11 + +* Fix missing `rule_set_ipcidr_match_source` item in DNS rules **1** +* Fixes and improvements + +**1**: + +See [DNS Rule](/configuration/dns/rule/). + +#### 1.9.0-alpha.10 + +* Add `bypass_domain` and `search_domain` platform HTTP proxy options **1** +* Fixes and improvements + +**1**: + +See [TUN](/configuration/inbound/tun) inbound. + +#### 1.9.0-alpha.8 + +* Add rejected DNS response cache support **1** +* Fixes and improvements + +**1**: + +The new feature allows you to cache the check results of +[Legacy Address Filter Fields](/configuration/dns/rule/#legacy-address-filter-fields) until expiration. + +#### 1.9.0-alpha.7 + +* Update gVisor to 20240206.0 +* Fixes and improvements + +#### 1.9.0-alpha.6 + +* Fixes and improvements + +#### 1.9.0-alpha.3 + +* Update `quic-go` to v0.41.0 +* Fixes and improvements + +#### 1.9.0-alpha.2 + +* Add support for `client-subnet` DNS options **1** +* Fixes and improvements + +**1**: + +See [DNS](/configuration/dns), [DNS Server](/configuration/dns/server) and [DNS Rules](/configuration/dns/rule). + +Since this feature makes the scenario mentioned in `alpha.1` no longer leak DNS requests, +the [Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users) has been updated. + +#### 1.9.0-alpha.1 + +* `domain_suffix` behavior update **1** +* `process_path` format update on Windows **2** +* Add address filter DNS rule items **3** + +**1**: + +See [Migration](/migration/#domain_suffix-behavior-update). + +**2**: + +See [Migration](/migration/#process_path-format-update-on-windows). + +**3**: + +The new DNS feature allows you to more precisely bypass Chinese websites via **DNS leaks**. Do not use plain local DNS +if using this method. + +See [Legacy Address Filter Fields](/configuration/dns/rule#legacy-address-filter-fields). + +[Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users) updated. + +#### 1.8.5 + +* Fixes and improvements + +#### 1.8.4 + +* Fixes and improvements + +#### 1.8.2 + +* Fixes and improvements + +#### 1.8.1 + +* Fixes and improvements + +### 1.8.0 + +* Fixes and improvements + +Important changes since 1.7: + +* Migrate cache file from Clash API to independent options **1** +* Introducing [rule-set](/configuration/rule-set/) **2** +* Add `sing-box geoip`, `sing-box geosite` and `sing-box rule-set` commands **3** +* Allow nested logical rules **4** +* Independent `source_ip_is_private` and `ip_is_private` rules **5** +* Add context to JSON decode error message **6** +* Reject internal fake-ip queries **7** +* Add GSO support for TUN and WireGuard system interface **8** +* Add `idle_timeout` for URLTest outbound **9** +* Add simple loopback detect +* Optimize memory usage of idle connections +* Update uTLS to 1.5.4 **10** +* Update dependencies **11** + +**1**: + +See [Cache File](/configuration/experimental/cache-file/) and +[Migration](/migration/#migrate-cache-file-from-clash-api-to-independent-options). + +**2**: + +rule-set is independent collections of rules that can be compiled into binaries to improve performance. +Compared to legacy GeoIP and Geosite resources, +it can include more types of rules, load faster, +use less memory, and update automatically. + +See [Route#rule_set](/configuration/route/#rule_set), +[Route Rule](/configuration/route/rule/), +[DNS Rule](/configuration/dns/rule/), +[rule-set](/configuration/rule-set/), +[Source Format](/configuration/rule-set/source-format/) and +[Headless Rule](/configuration/rule-set/headless-rule/). + +For GEO resources migration, see [Migrate GeoIP to rule-sets](/migration/#migrate-geoip-to-rule-sets) and +[Migrate Geosite to rule-sets](/migration/#migrate-geosite-to-rule-sets). + +**3**: + +New commands manage GeoIP, Geosite and rule-set resources, and help you migrate GEO resources to rule-sets. + +**4**: + +Logical rules in route rules, DNS rules, and the new headless rule now allow nesting of logical rules. + +**5**: + +The `private` GeoIP country never existed and was actually implemented inside V2Ray. +Since GeoIP was deprecated, we made this rule independent, see [Migration](/migration/#migrate-geoip-to-rule-sets). + +**6**: + +JSON parse errors will now include the current key path. +Only takes effect when compiled with Go 1.21+. + +**7**: + +All internal DNS queries now skip DNS rules with `server` type `fakeip`, +and the default DNS server can no longer be `fakeip`. + +This change is intended to break incorrect usage and essentially requires no action. + +**8**: + +See [TUN](/configuration/inbound/tun/) inbound and [WireGuard](/configuration/outbound/wireguard/) outbound. + +**9**: + +When URLTest is idle for a certain period of time, the scheduled delay test will be paused. + +**10**: + +Added some new [fingerprints](/configuration/shared/tls#utls). +Also, starting with this release, uTLS requires at least Go 1.20. + +**11**: + +Updated `cloudflare-tls`, `gomobile`, `smux`, `tfo-go` and `wireguard-go` to latest, `quic-go` to `0.40.1` and `gvisor` +to `20231204.0` + +#### 1.8.0-rc.11 + +* Fixes and improvements + +#### 1.7.8 + +* Fixes and improvements + +#### 1.8.0-rc.10 + +* Fixes and improvements + +#### 1.7.7 + +* Fix V2Ray transport `path` validation behavior **1** +* Fixes and improvements + +**1**: + +See [V2Ray transport](/configuration/shared/v2ray-transport/). + +#### 1.8.0-rc.7 + +* Fixes and improvements + +#### 1.8.0-rc.3 + +* Fix V2Ray transport `path` validation behavior **1** +* Fixes and improvements + +**1**: + +See [V2Ray transport](/configuration/shared/v2ray-transport/). + +#### 1.7.6 + +* Fixes and improvements + +#### 1.8.0-rc.1 + +* Fixes and improvements + +#### 1.8.0-beta.9 + +* Add simple loopback detect +* Fixes and improvements + +#### 1.7.5 + +* Fixes and improvements + +#### 1.8.0-alpha.17 + +* Add GSO support for TUN and WireGuard system interface **1** +* Update uTLS to 1.5.4 **2** +* Update dependencies **3** +* Fixes and improvements + +**1**: + +See [TUN](/configuration/inbound/tun/) inbound and [WireGuard](/configuration/outbound/wireguard/) outbound. + +**2**: + +Added some new [fingerprints](/configuration/shared/tls#utls). +Also, starting with this release, uTLS requires at least Go 1.20. + +**3**: + +Updated `cloudflare-tls`, `gomobile`, `smux`, `tfo-go` and `wireguard-go` to latest, and `gvisor` to `20231204.0` + +This may break something, good luck! + +#### 1.7.4 + +* Fixes and improvements + +_Due to the long waiting time, this version is no longer waiting for approval +by the Apple App Store, so updates to Apple Platforms will be delayed._ + +#### 1.8.0-alpha.16 + +* Fixes and improvements + +#### 1.8.0-alpha.15 + +* Some chaotic changes **1** +* Fixes and improvements + +**1**: + +Designed to optimize memory usage of idle connections, may take effect on the following protocols: + +| Protocol | TCP | UDP | +|------------------------------------------------------|------------------|------------------| +| HTTP proxy server | :material-check: | / | +| SOCKS5 | :material-close: | :material-check: | +| Shadowsocks none/AEAD/AEAD2022 | :material-check: | :material-check: | +| Trojan | / | :material-check: | +| TUIC/Hysteria/Hysteria2 | :material-close: | :material-check: | +| Multiplex | :material-close: | :material-check: | +| Plain TLS (Trojan/VLESS without extra sub-protocols) | :material-check: | / | +| Other protocols | :material-close: | :material-close: | + +At the same time, everything existing may be broken, please actively report problems with this version. + +#### 1.8.0-alpha.13 + +* Fixes and improvements + +#### 1.8.0-alpha.10 + +* Add `idle_timeout` for URLTest outbound **1** +* Fixes and improvements + +**1**: + +When URLTest is idle for a certain period of time, the scheduled delay test will be paused. + +#### 1.7.2 + +* Fixes and improvements + +#### 1.8.0-alpha.8 + +* Add context to JSON decode error message **1** +* Reject internal fake-ip queries **2** +* Fixes and improvements + +**1**: + +JSON parse errors will now include the current key path. +Only takes effect when compiled with Go 1.21+. + +**2**: + +All internal DNS queries now skip DNS rules with `server` type `fakeip`, +and the default DNS server can no longer be `fakeip`. + +This change is intended to break incorrect usage and essentially requires no action. + +#### 1.8.0-alpha.7 + +* Fixes and improvements + +#### 1.7.1 + +* Fixes and improvements + +#### 1.8.0-alpha.6 + +* Fix rule-set matching logic **1** +* Fixes and improvements + +**1**: + +Now the rules in the `rule_set` rule item can be logically considered to be merged into the rule using rule-sets, +rather than completely following the AND logic. + +#### 1.8.0-alpha.5 + +* Parallel rule-set initialization +* Independent `source_ip_is_private` and `ip_is_private` rules **1** + +**1**: + +The `private` GeoIP country never existed and was actually implemented inside V2Ray. +Since GeoIP was deprecated, we made this rule independent, see [Migration](/migration/#migrate-geoip-to-rule-sets). + +#### 1.8.0-alpha.1 + +* Migrate cache file from Clash API to independent options **1** +* Introducing [rule-set](/configuration/rule-set/) **2** +* Add `sing-box geoip`, `sing-box geosite` and `sing-box rule-set` commands **3** +* Allow nested logical rules **4** + +**1**: + +See [Cache File](/configuration/experimental/cache-file/) and +[Migration](/migration/#migrate-cache-file-from-clash-api-to-independent-options). + +**2**: + +rule-set is independent collections of rules that can be compiled into binaries to improve performance. +Compared to legacy GeoIP and Geosite resources, +it can include more types of rules, load faster, +use less memory, and update automatically. + +See [Route#rule_set](/configuration/route/#rule_set), +[Route Rule](/configuration/route/rule/), +[DNS Rule](/configuration/dns/rule/), +[rule-set](/configuration/rule-set/), +[Source Format](/configuration/rule-set/source-format/) and +[Headless Rule](/configuration/rule-set/headless-rule/). + +For GEO resources migration, see [Migrate GeoIP to rule-sets](/migration/#migrate-geoip-to-rule-sets) and +[Migrate Geosite to rule-sets](/migration/#migrate-geosite-to-rule-sets). + +**3**: + +New commands manage GeoIP, Geosite and rule-set resources, and help you migrate GEO resources to rule-sets. + +**4**: + +Logical rules in route rules, DNS rules, and the new headless rule now allow nesting of logical rules. + +### 1.7.0 + +* Fixes and improvements + +Important changes since 1.6: + +* Add [exclude route support](/configuration/inbound/tun/) for TUN inbound +* Add `udp_disable_domain_unmapping` [inbound listen option](/configuration/shared/listen/) **1** +* Add [HTTPUpgrade V2Ray transport](/configuration/shared/v2ray-transport#HTTPUpgrade) support **2** +* Migrate multiplex and UoT server to inbound **3** +* Add TCP Brutal support for multiplex **4** +* Add `wifi_ssid` and `wifi_bssid` route and DNS rules **5** +* Update quic-go to v0.40.0 +* Update gVisor to 20231113.0 + +**1**: + +If enabled, for UDP proxy requests addressed to a domain, +the original packet address will be sent in the response instead of the mapped domain. + +This option is used for compatibility with clients that +do not support receiving UDP packets with domain addresses, such as Surge. + +**2**: + +Introduced in V2Ray 5.10.0. + +The new HTTPUpgrade transport has better performance than WebSocket and is better suited for CDN abuse. + +**3**: + +Starting in 1.7.0, multiplexing support is no longer enabled by default +and needs to be turned on explicitly in inbound +options. + +**4** + +Hysteria Brutal Congestion Control Algorithm in TCP. A kernel module needs to be installed on the Linux server, +see [TCP Brutal](/configuration/shared/tcp-brutal/) for details. + +**5**: + +Only supported in graphical clients on Android and Apple platforms. + +#### 1.7.0-rc.3 + +* Fixes and improvements + +#### 1.6.7 + +* macOS: Add button for uninstall SystemExtension in the standalone graphical client +* Fix missing UDP user context on TUIC/Hysteria2 inbounds +* Fixes and improvements + +#### 1.7.0-rc.2 + +* Fix missing UDP user context on TUIC/Hysteria2 inbounds +* macOS: Add button for uninstall SystemExtension in the standalone graphical client + +#### 1.6.6 + +* Fixes and improvements + +#### 1.7.0-rc.1 + +* Fixes and improvements + +#### 1.7.0-beta.5 + +* Update gVisor to 20231113.0 +* Fixes and improvements + +#### 1.7.0-beta.4 + +* Add `wifi_ssid` and `wifi_bssid` route and DNS rules **1** +* Fixes and improvements + +**1**: + +Only supported in graphical clients on Android and Apple platforms. + +#### 1.7.0-beta.3 + +* Fix zero TTL was incorrectly reset +* Fixes and improvements + +#### 1.6.5 + +* Fix crash if TUIC inbound authentication failed +* Fixes and improvements + +#### 1.7.0-beta.2 + +* Fix crash if TUIC inbound authentication failed +* Update quic-go to v0.40.0 +* Fixes and improvements + +#### 1.6.4 + +* Fixes and improvements + +#### 1.7.0-beta.1 + +* Fixes and improvements + +#### 1.6.3 + +* iOS/Android: Fix profile auto update +* Fixes and improvements + +#### 1.7.0-alpha.11 + +* iOS/Android: Fix profile auto update +* Fixes and improvements + +#### 1.7.0-alpha.10 + +* Fix tcp-brutal not working with TLS +* Fix Android client not closing in some cases +* Fixes and improvements + +#### 1.6.2 + +* Fixes and improvements + +#### 1.6.1 + +* Our [Android client](/installation/clients/sfa/) is now available in the Google Play Store ▶️ +* Fixes and improvements + +#### 1.7.0-alpha.6 + +* Fixes and improvements + +#### 1.7.0-alpha.4 + +* Migrate multiplex and UoT server to inbound **1** +* Add TCP Brutal support for multiplex **2** + +**1**: + +Starting in 1.7.0, multiplexing support is no longer enabled by default and needs to be turned on explicitly in inbound +options. + +**2** + +Hysteria Brutal Congestion Control Algorithm in TCP. A kernel module needs to be installed on the Linux server, +see [TCP Brutal](/configuration/shared/tcp-brutal/) for details. + +#### 1.7.0-alpha.3 + +* Add [HTTPUpgrade V2Ray transport](/configuration/shared/v2ray-transport#HTTPUpgrade) support **1** +* Fixes and improvements + +**1**: + +Introduced in V2Ray 5.10.0. + +The new HTTPUpgrade transport has better performance than WebSocket and is better suited for CDN abuse. + +### 1.6.0 + +* Fixes and improvements + +Important changes since 1.5: + +* Our [Apple tvOS client](/installation/clients/sft/) is now available in the App Store 🍎 +* Update BBR congestion control for TUIC and Hysteria2 **1** +* Update brutal congestion control for Hysteria2 +* Add `brutal_debug` option for Hysteria2 +* Update legacy Hysteria protocol **2** +* Add TLS self sign key pair generate command +* Remove [Deprecated Features](/deprecated/) by agreement + +**1**: + +None of the existing Golang BBR congestion control implementations have been reviewed or unit tested. +This update is intended to address the multi-send defects of the old implementation and may introduce new issues. + +**2** + +Based on discussions with the original author, the brutal CC and QUIC protocol parameters of +the old protocol (Hysteria 1) have been updated to be consistent with Hysteria 2 + +#### 1.7.0-alpha.2 + +* Fix bugs introduced in 1.7.0-alpha.1 + +#### 1.7.0-alpha.1 + +* Add [exclude route support](/configuration/inbound/tun/) for TUN inbound +* Add `udp_disable_domain_unmapping` [inbound listen option](/configuration/shared/listen/) **1** +* Fixes and improvements + +**1**: + +If enabled, for UDP proxy requests addressed to a domain, +the original packet address will be sent in the response instead of the mapped domain. + +This option is used for compatibility with clients that +do not support receiving UDP packets with domain addresses, such as Surge. + +#### 1.5.5 + +* Fix IPv6 `auto_route` for Linux **1** +* Add legacy builds for old Windows and macOS systems **2** +* Fixes and improvements + +**1**: + +When `auto_route` is enabled and `strict_route` is disabled, the device can now be reached from external IPv6 addresses. + +**2**: + +Built using Go 1.20, the last version that will run on +Windows 7, 8, Server 2008, Server 2012 and macOS 10.13 High +Sierra, 10.14 Mojave. + +#### 1.6.0-rc.4 + +* Fixes and improvements + +#### 1.6.0-rc.1 + +* Add legacy builds for old Windows and macOS systems **1** +* Fixes and improvements + +**1**: + +Built using Go 1.20, the last version that will run on +Windows 7, 8, Server 2008, Server 2012 and macOS 10.13 High +Sierra, 10.14 Mojave. + +#### 1.6.0-beta.4 + +* Fix IPv6 `auto_route` for Linux **1** +* Fixes and improvements + +**1**: + +When `auto_route` is enabled and `strict_route` is disabled, the device can now be reached from external IPv6 addresses. + +#### 1.5.4 + +* Fix Clash cache crash on arm32 devices +* Fixes and improvements + +#### 1.6.0-beta.3 + +* Update the legacy Hysteria protocol **1** +* Fixes and improvements + +**1** + +Based on discussions with the original author, the brutal CC and QUIC protocol parameters of +the old protocol (Hysteria 1) have been updated to be consistent with Hysteria 2 + +#### 1.6.0-beta.2 + +* Add TLS self sign key pair generate command +* Update brutal congestion control for Hysteria2 +* Fix Clash cache crash on arm32 devices +* Update golang.org/x/net to v0.17.0 +* Fixes and improvements + +#### 1.6.0-beta.3 + +* Update the legacy Hysteria protocol **1** +* Fixes and improvements + +**1** + +Based on discussions with the original author, the brutal CC and QUIC protocol parameters of +the old protocol (Hysteria 1) have been updated to be consistent with Hysteria 2 + +#### 1.6.0-beta.2 + +* Add TLS self sign key pair generate command +* Update brutal congestion control for Hysteria2 +* Fix Clash cache crash on arm32 devices +* Update golang.org/x/net to v0.17.0 +* Fixes and improvements + +#### 1.5.3 + +* Fix compatibility with Android 14 +* Fixes and improvements + +#### 1.6.0-beta.1 + +* Fixes and improvements + +#### 1.6.0-alpha.5 + +* Fix compatibility with Android 14 +* Update BBR congestion control for TUIC and Hysteria2 **1** +* Fixes and improvements + +**1**: + +None of the existing Golang BBR congestion control implementations have been reviewed or unit tested. +This update is intended to fix a memory leak flaw in the new implementation introduced in 1.6.0-alpha.1 and may +introduce new issues. + +#### 1.6.0-alpha.4 + +* Add `brutal_debug` option for Hysteria2 +* Fixes and improvements + +#### 1.5.2 + +* Our [Apple tvOS client](/installation/clients/sft/) is now available in the App Store 🍎 +* Fixes and improvements + +#### 1.6.0-alpha.3 + +* Fixes and improvements + +#### 1.6.0-alpha.2 + +* Fixes and improvements + +#### 1.5.1 + +* Fixes and improvements + +#### 1.6.0-alpha.1 + +* Update BBR congestion control for TUIC and Hysteria2 **1** +* Update quic-go to v0.39.0 +* Update gVisor to 20230814.0 +* Remove [Deprecated Features](/deprecated/) by agreement +* Fixes and improvements + +**1**: + +None of the existing Golang BBR congestion control implementations have been reviewed or unit tested. +This update is intended to address the multi-send defects of the old implementation and may introduce new issues. + +### 1.5.0 + +* Fixes and improvements + +Important changes since 1.4: + +* Add TLS [ECH server](/configuration/shared/tls/) support +* Improve TLS TCH client configuration +* Add TLS ECH key pair generator **1** +* Add TLS ECH support for QUIC based protocols **2** +* Add KDE support for the `set_system_proxy` option in HTTP inbound +* Add Hysteria2 protocol support **3** +* Add `interrupt_exist_connections` option for `Selector` and `URLTest` outbounds **4** +* Add DNS01 challenge support for ACME TLS certificate issuer **5** +* Add `merge` command **6** +* Mark [Deprecated Features](/deprecated/) + +**1**: + +Command: `sing-box generate ech-keypair [--pq-signature-schemes-enabled]` + +**2**: + +All inbounds and outbounds are supported, including `Naiveproxy`, `Hysteria[/2]`, `TUIC` and `V2ray QUIC transport`. + +**3**: + +See [Hysteria2 inbound](/configuration/inbound/hysteria2/) and [Hysteria2 outbound](/configuration/outbound/hysteria2/) + +For protocol description, please refer to [https://v2.hysteria.network](https://v2.hysteria.network) + +**4**: + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. + +**5**: + +Only `Alibaba Cloud DNS` and `Cloudflare` are supported, see [ACME Fields](/configuration/shared/tls#acme-fields) +and [DNS01 Challenge Fields](/configuration/shared/dns01_challenge/). + +**6**: + +This command also parses path resources that appear in the configuration file and replaces them with embedded +configuration, such as TLS certificates or SSH private keys. + +#### 1.5.0-rc.6 + +* Fixes and improvements + +#### 1.4.6 + +* Fixes and improvements + +#### 1.5.0-rc.5 + +* Fixed an improper authentication vulnerability in the SOCKS5 inbound +* Fixes and improvements + +**Security Advisory** + +This update fixes an improper authentication vulnerability in the sing-box SOCKS inbound. This vulnerability allows an +attacker to craft special requests to bypass user authentication. All users exposing SOCKS servers with user +authentication in an insecure environment are advised to update immediately. + +此更新修复了 sing-box SOCKS 入站中的一个不正确身份验证漏洞。 该漏洞允许攻击者制作特殊请求来绕过用户身份验证。建议所有将使用用户认证的 +SOCKS 服务器暴露在不安全环境下的用户立更新。 + +#### 1.4.5 + +* Fixed an improper authentication vulnerability in the SOCKS5 inbound +* Fixes and improvements + +**Security Advisory** + +This update fixes an improper authentication vulnerability in the sing-box SOCKS inbound. This vulnerability allows an +attacker to craft special requests to bypass user authentication. All users exposing SOCKS servers with user +authentication in an insecure environment are advised to update immediately. + +此更新修复了 sing-box SOCKS 入站中的一个不正确身份验证漏洞。 该漏洞允许攻击者制作特殊请求来绕过用户身份验证。建议所有将使用用户认证的 +SOCKS 服务器暴露在不安全环境下的用户立更新。 + +#### 1.5.0-rc.3 + +* Fixes and improvements + +#### 1.5.0-beta.12 + +* Add `merge` command **1** +* Fixes and improvements + +**1**: + +This command also parses path resources that appear in the configuration file and replaces them with embedded +configuration, such as TLS certificates or SSH private keys. + +``` +Merge configurations + +Usage: + sing-box merge [output] [flags] + +Flags: + -h, --help help for merge + +Global Flags: + -c, --config stringArray set configuration file path + -C, --config-directory stringArray set configuration directory path + -D, --directory string set working directory + --disable-color disable color output +``` + +#### 1.5.0-beta.11 + +* Add DNS01 challenge support for ACME TLS certificate issuer **1** +* Fixes and improvements + +**1**: + +Only `Alibaba Cloud DNS` and `Cloudflare` are supported, +see [ACME Fields](/configuration/shared/tls#acme-fields) +and [DNS01 Challenge Fields](/configuration/shared/dns01_challenge/). + +#### 1.5.0-beta.10 + +* Add `interrupt_exist_connections` option for `Selector` and `URLTest` outbounds **1** +* Fixes and improvements + +**1**: + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. + +#### 1.4.3 + +* Fixes and improvements + +#### 1.5.0-beta.8 + +* Fixes and improvements + +#### 1.4.2 + +* Fixes and improvements + +#### 1.5.0-beta.6 + +* Fix compatibility issues with official Hysteria2 server and client +* Fixes and improvements +* Mark [deprecated features](/deprecated/) + +#### 1.5.0-beta.3 + +* Fixes and improvements +* Updated Hysteria2 documentation **1** + +**1**: + +Added notes indicating compatibility issues with the official +Hysteria2 server and client when using `fastOpen=false` or UDP MTU >= 1200. + +#### 1.5.0-beta.2 + +* Add hysteria2 protocol support **1** +* Fixes and improvements + +**1**: + +See [Hysteria2 inbound](/configuration/inbound/hysteria2/) and [Hysteria2 outbound](/configuration/outbound/hysteria2/) + +For protocol description, please refer to [https://v2.hysteria.network](https://v2.hysteria.network) + +#### 1.5.0-beta.1 + +* Add TLS [ECH server](/configuration/shared/tls/) support +* Improve TLS TCH client configuration +* Add TLS ECH key pair generator **1** +* Add TLS ECH support for QUIC based protocols **2** +* Add KDE support for the `set_system_proxy` option in HTTP inbound + +**1**: + +Command: `sing-box generate ech-keypair [--pq-signature-schemes-enabled]` + +**2**: + +All inbounds and outbounds are supported, including `Naiveproxy`, `Hysteria`, `TUIC` and `V2ray QUIC transport`. + +#### 1.4.1 + +* Fixes and improvements + +### 1.4.0 + +* Fix bugs and update dependencies + +Important changes since 1.3: + +* Add TUIC support **1** +* Add `udp_over_stream` option for TUIC client **2** +* Add MultiPath TCP support **3** +* Add `include_interface` and `exclude_interface` options for tun inbound +* Pause recurring tasks when no network or device idle +* Improve Android and Apple platform clients + +*1*: + +See [TUIC inbound](/configuration/inbound/tuic/) +and [TUIC outbound](/configuration/outbound/tuic/) + +**2**: + +This is the TUIC port of the [UDP over TCP protocol](/configuration/shared/udp-over-tcp/), designed to provide a QUIC +stream based UDP relay mode that TUIC does not provide. Since it is an add-on protocol, you will need to use sing-box or +another program compatible with the protocol as a server. + +This mode has no positive effect in a proper UDP proxy scenario and should only be applied to relay streaming UDP +traffic (basically QUIC streams). + +*3*: + +Requires sing-box to be compiled with Go 1.21. + +#### 1.4.0-rc.3 + +* Fixes and improvements + +#### 1.4.0-rc.2 + +* Fixes and improvements + +#### 1.4.0-rc.1 + +* Fix TUIC UDP + +#### 1.4.0-beta.6 + +* Add `udp_over_stream` option for TUIC client **1** +* Add `include_interface` and `exclude_interface` options for tun inbound +* Fixes and improvements + +**1**: + +This is the TUIC port of the [UDP over TCP protocol](/configuration/shared/udp-over-tcp/), designed to provide a QUIC +stream based UDP relay mode that TUIC does not provide. Since it is an add-on protocol, you will need to use sing-box or +another program compatible with the protocol as a server. + +This mode has no positive effect in a proper UDP proxy scenario and should only be applied to relay streaming UDP +traffic (basically QUIC streams). + +#### 1.4.0-beta.5 + +* Fixes and improvements + +#### 1.4.0-beta.4 + +* Graphical clients: Persistence group expansion state +* Fixes and improvements + +#### 1.4.0-beta.3 + +* Fixes and improvements + +#### 1.4.0-beta.2 + +* Add MultiPath TCP support **1** +* Drop QUIC support for Go 1.18 and 1.19 due to upstream changes +* Fixes and improvements + +*1*: + +Requires sing-box to be compiled with Go 1.21. + +#### 1.4.0-beta.1 + +* Add TUIC support **1** +* Pause recurring tasks when no network or device idle +* Fixes and improvements + +*1*: + +See [TUIC inbound](/configuration/inbound/tuic/) +and [TUIC outbound](/configuration/outbound/tuic/) + +#### 1.3.6 + +* Fixes and improvements + +#### 1.3.5 + +* Fixes and improvements +* Introducing our [Apple tvOS](/installation/clients/sft/) client applications **1** +* Add per app proxy and app installed/updated trigger support for Android client +* Add profile sharing support for Android/iOS/macOS clients + +**1**: + +Due to the requirement of tvOS 17, the app cannot be submitted to the App Store for the time being, and can only be +downloaded through TestFlight. + +#### 1.3.4 + +* Fixes and improvements +* We're now on the [App Store](https://apps.apple.com/us/app/sing-box/id6451272673), always free! It should be noted + that due to stricter and slower review, the release of Store versions will be delayed. +* We've made a standalone version of the macOS client (the original Application Extension relies on App Store + distribution), which you can download as SFM-version-universal.zip in the release artifacts. + +#### 1.3.3 + +* Fixes and improvements + +#### 1.3.1-rc.1 + +* Fix bugs and update dependencies + +#### 1.3.1-beta.3 + +* Introducing our [new iOS](/installation/clients/sfi/) and [macOS](/installation/clients/sfm/) client applications **1 + ** +* Fixes and improvements + +**1**: + +The old testflight link and app are no longer valid. + +#### 1.3.1-beta.2 + +* Fix bugs and update dependencies + +#### 1.3.1-beta.1 + +* Fixes and improvements + +### 1.3.0 + +* Fix bugs and update dependencies + +Important changes since 1.2: + +* Add [FakeIP](/configuration/dns/fakeip/) support **1** +* Improve multiplex **2** +* Add [DNS reverse mapping](/configuration/dns#reverse_mapping) support +* Add `rewrite_ttl` DNS rule action +* Add `store_fakeip` Clash API option +* Add multi-peer support for [WireGuard](/configuration/outbound/wireguard#peers) outbound +* Add loopback detect +* Add Clash.Meta API compatibility for Clash API +* Download Yacd-meta by default if the specified Clash `external_ui` directory is empty +* Add path and headers option for HTTP outbound +* Perform URLTest recheck after network changes +* Fix `system` tun stack for ios +* Fix network monitor for android/ios +* Update VLESS and XUDP protocol +* Make splice work with traffic statistics systems like Clash API +* Significantly reduces memory usage of idle connections +* Improve DNS caching +* Add `independent_cache` [option](/configuration/dns#independent_cache) for DNS +* Reimplemented shadowsocks client +* Add multiplex support for VLESS outbound +* Automatically add Windows firewall rules in order for the system tun stack to work +* Fix TLS 1.2 support for shadow-tls client +* Add `cache_id` [option](/configuration/experimental#cache_id) for Clash cache file +* Fix `local` DNS transport for Android + +*1*: + +See [FAQ](/faq/fakeip/) for more information. + +*2*: + +Added new `h2mux` multiplex protocol and `padding` multiplex option, see [Multiplex](/configuration/shared/multiplex/). + +#### 1.3-rc2 + +* Fix `local` DNS transport for Android +* Fix bugs and update dependencies + +#### 1.3-rc1 + +* Fix bugs and update dependencies + +#### 1.3-beta14 + +* Fixes and improvements + +#### 1.3-beta13 + +* Fix resolving fakeip domains **1** +* Deprecate L3 routing +* Fix bugs and update dependencies + +**1**: + +If the destination address of the connection is obtained from fakeip, dns rules with server type fakeip will be skipped. + +#### 1.3-beta12 + +* Automatically add Windows firewall rules in order for the system tun stack to work +* Fix TLS 1.2 support for shadow-tls client +* Add `cache_id` [option](/configuration/experimental#cache_id) for Clash cache file +* Fixes and improvements + +#### 1.3-beta11 + +* Fix bugs and update dependencies + +#### 1.3-beta10 + +* Improve direct copy **1** +* Improve DNS caching +* Add `independent_cache` [option](/configuration/dns#independent_cache) for DNS +* Reimplemented shadowsocks client **2** +* Add multiplex support for VLESS outbound +* Set TCP keepalive for WireGuard gVisor TCP connections +* Fixes and improvements + +**1**: + +* Make splice work with traffic statistics systems like Clash API +* Significantly reduces memory usage of idle connections + +**2**: + +Improved performance and reduced memory usage. + +#### 1.3-beta9 + +* Improve multiplex **1** +* Fixes and improvements + +*1*: + +Added new `h2mux` multiplex protocol and `padding` multiplex option, see [Multiplex](/configuration/shared/multiplex/). + +#### 1.2.6 + +* Fix bugs and update dependencies + +#### 1.3-beta8 + +* Fix `system` tun stack for ios +* Fix network monitor for android/ios +* Update VLESS and XUDP protocol **1** +* Fixes and improvements + +*1: + +This is an incompatible update for XUDP in VLESS if vision flow is enabled. + +#### 1.3-beta7 + +* Add `path` and `headers` options for HTTP outbound +* Add multi-user support for Shadowsocks legacy AEAD inbound +* Fixes and improvements + +#### 1.2.4 + +* Fixes and improvements + +#### 1.3-beta6 + +* Fix WireGuard reconnect +* Perform URLTest recheck after network changes +* Fix bugs and update dependencies + +#### 1.3-beta5 + +* Add Clash.Meta API compatibility for Clash API +* Download Yacd-meta by default if the specified Clash `external_ui` directory is empty +* Add path and headers option for HTTP outbound +* Fixes and improvements + +#### 1.3-beta4 + +* Fix bugs + +#### 1.3-beta2 + +* Download clash-dashboard if the specified Clash `external_ui` directory is empty +* Fix bugs and update dependencies + +#### 1.3-beta1 + +* Add [DNS reverse mapping](/configuration/dns#reverse_mapping) support +* Add [L3 routing](/configuration/route/ip-rule/) support **1** +* Add `rewrite_ttl` DNS rule action +* Add [FakeIP](/configuration/dns/fakeip/) support **2** +* Add `store_fakeip` Clash API option +* Add multi-peer support for [WireGuard](/configuration/outbound/wireguard#peers) outbound +* Add loopback detect + +*1*: + +It can currently be used to [route connections directly to WireGuard](/examples/wireguard-direct/) or block connections +at the IP layer. + +*2*: + +See [FAQ](/faq/fakeip/) for more information. + +#### 1.2.3 + +* Introducing our [new Android client application](/installation/clients/sfa/) +* Improve UDP domain destination NAT +* Update reality protocol +* Fix TTL calculation for DNS response +* Fix v2ray HTTP transport compatibility +* Fix bugs and update dependencies + +#### 1.2.2 + +* Accept `any` outbound in dns rule **1** +* Fix bugs and update dependencies + +*1*: + +Now you can use the `any` outbound rule to match server address queries instead of filling in all server domains +to `domain` rule. + +#### 1.2.1 + +* Fix missing default host in v2ray http transport`s request +* Flush DNS cache for macOS when tun start/close +* Fix tun's DNS hijacking compatibility with systemd-resolved + +### 1.2.0 + +* Fix bugs and update dependencies + +Important changes since 1.1: + +* Introducing our [new iOS client application](/installation/clients/sfi/) +* Introducing [UDP over TCP protocol version 2](/configuration/shared/udp-over-tcp/) +* Add [platform options](/configuration/inbound/tun#platform) for tun inbound +* Add [ShadowTLS protocol v3](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-v3-en.md) +* Add [VLESS server](/configuration/inbound/vless/) and [vision](/configuration/outbound/vless#flow) support +* Add [reality TLS](/configuration/shared/tls/) support +* Add [NTP service](/configuration/ntp/) +* Add [DHCP DNS server](/configuration/dns/server/) support +* Add SSH [host key validation](/configuration/outbound/ssh/) support +* Add [query_type](/configuration/dns/rule/) DNS rule item +* Add fallback support for v2ray transport +* Add custom TLS server support for http based v2ray transports +* Add health check support for http-based v2ray transports +* Add multiple configuration support + +#### 1.2-rc1 + +* Fix bugs and update dependencies + +#### 1.2-beta10 + +* Add multiple configuration support **1** +* Fix bugs and update dependencies + +*1*: + +Now you can pass the parameter `--config` or `-c` multiple times, or use the new parameter `--config-directory` or `-C` +to load all configuration files in a directory. + +Loaded configuration files are sorted by name. If you want to control the merge order, add a numeric prefix to the file +name. + +#### 1.1.7 + +* Improve the stability of the VMESS server +* Fix `auto_detect_interface` incorrectly identifying the default interface on Windows +* Fix bugs and update dependencies + +#### 1.2-beta9 + +* Introducing the [UDP over TCP protocol version 2](/configuration/shared/udp-over-tcp/) +* Add health check support for http-based v2ray transports +* Remove length limit on short_id for reality TLS config +* Fix bugs and update dependencies + +#### 1.2-beta8 + +* Update reality and uTLS libraries +* Fix `auto_detect_interface` incorrectly identifying the default interface on Windows + +#### 1.2-beta7 + +* Fix the compatibility issue between VLESS's vision sub-protocol and the Xray-core client +* Improve the stability of the VMESS server + +#### 1.2-beta6 + +* Introducing our [new iOS client application](/installation/clients/sfi/) +* Add [platform options](/configuration/inbound/tun#platform) for tun inbound +* Add custom TLS server support for http based v2ray transports +* Add generate commands +* Enable XUDP by default in VLESS +* Update reality server +* Update vision protocol +* Fixed [user flow in vless server](/configuration/inbound/vless#usersflow) +* Bug fixes +* Update dependencies + +#### 1.2-beta5 + +* Add [VLESS server](/configuration/inbound/vless/) and [vision](/configuration/outbound/vless#flow) support +* Add [reality TLS](/configuration/shared/tls/) support +* Fix match private address + +#### 1.1.6 + +* Improve vmess request +* Fix ipv6 redirect on Linux +* Fix match geoip private +* Fix parse hysteria UDP message +* Fix socks connect response +* Disable vmess header protection if transport enabled +* Update QUIC v2 version number and initial salt + +#### 1.2-beta4 + +* Add [NTP service](/configuration/ntp/) +* Add Add multiple server names and multi-user support for shadowtls +* Add strict mode support for shadowtls v3 +* Add uTLS support for shadowtls v3 + +#### 1.2-beta3 + +* Update QUIC v2 version number and initial salt +* Fix shadowtls v3 implementation + +#### 1.2-beta2 + +* Add [ShadowTLS protocol v3](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-v3-en.md) +* Add fallback support for v2ray transport +* Fix parse hysteria UDP message +* Fix socks connect response +* Disable vmess header protection if transport enabled + +#### 1.2-beta1 + +* Add [DHCP DNS server](/configuration/dns/server/) support +* Add SSH [host key validation](/configuration/outbound/ssh/) support +* Add [query_type](/configuration/dns/rule/) DNS rule item +* Add v2ray [user stats](/configuration/experimental#statsusers) api +* Add new clash DNS query api +* Improve vmess request +* Fix ipv6 redirect on Linux +* Fix match geoip private + +#### 1.1.5 + +* Add Go 1.20 support +* Fix inbound default DF value +* Fix auth_user route for naive inbound +* Fix gRPC lite header +* Ignore domain case in route rules + +#### 1.1.4 + +* Fix DNS log +* Fix write to h2 conn after closed +* Fix create UDP DNS transport from plain IPv6 address + +#### 1.1.2 + +* Fix http proxy auth +* Fix user from stream packet conn +* Fix DNS response TTL +* Fix override packet conn +* Skip override system proxy bypass list +* Improve DNS log + +#### 1.1.1 + +* Fix acme config +* Fix vmess packet conn +* Suppress quic-go set DF error + +#### 1.1 + +* Fix close clash cache + +Important changes since 1.0: + +* Add support for use with android VPNService +* Add tun support for WireGuard outbound +* Add system tun stack +* Add comment filter for config +* Add option for allow optional proxy protocol header +* Add Clash mode and persistence support +* Add TLS ECH and uTLS support for outbound TLS options +* Add internal simple-obfs and v2ray-plugin +* Add ShadowsocksR outbound +* Add VLESS outbound and XUDP +* Skip wait for hysteria tcp handshake response +* Add v2ray mux support for all inbound +* Add XUDP support for VMess +* Improve websocket writer +* Refine tproxy write back +* Fix DNS leak caused by + Windows' ordinary multihomed DNS resolution behavior +* Add sniff_timeout listen option +* Add custom route support for tun +* Add option for custom wireguard reserved bytes +* Split bind_address into ipv4 and ipv6 +* Add ShadowTLS v1 and v2 support + +#### 1.1-rc1 + +* Fix TLS config for h2 server +* Fix crash when input bad method in shadowsocks multi-user inbound +* Fix listen UDP +* Fix check invalid packet on macOS + +#### 1.1-beta18 + +* Enhance defense against active probe for shadowtls server **1** + +**1**: + +The `fallback_after` option has been removed. + +#### 1.1-beta17 + +* Fix shadowtls server **1** + +*1*: + +Added [fallback_after](/configuration/inbound/shadowtls#fallback_after) option. + +#### 1.0.7 + +* Add support for new x/h2 deadline +* Fix copy pipe +* Fix decrypt xplus packet +* Fix macOS Ventura process name match +* Fix smux keepalive +* Fix vmess request buffer +* Fix h2c transport +* Fix tor geoip +* Fix udp connect for mux client +* Fix default dns transport strategy + +#### 1.1-beta16 + +* Improve shadowtls server +* Fix default dns transport strategy +* Update uTLS to v1.2.0 + +#### 1.1-beta15 + +* Add support for new x/h2 deadline +* Fix udp connect for mux client +* Fix dns buffer +* Fix quic dns retry +* Fix create TLS config +* Fix websocket alpn +* Fix tor geoip + +#### 1.1-beta14 + +* Add multi-user support for hysteria inbound **1** +* Add custom tls client support for std grpc +* Fix smux keep alive +* Fix vmess request buffer +* Fix default local DNS server behavior +* Fix h2c transport + +*1*: + +The `auth` and `auth_str` fields have been replaced by the `users` field. + +#### 1.1-beta13 + +* Add custom worker count option for WireGuard outbound +* Split bind_address into ipv4 and ipv6 +* Move WFP manipulation to strict route +* Fix WireGuard outbound panic when close +* Fix macOS Ventura process name match +* Fix QUIC connection migration by @HyNetwork +* Fix handling QUIC client SNI by @HyNetwork + +#### 1.1-beta12 + +* Fix uTLS config +* Update quic-go to v0.30.0 +* Update cloudflare-tls to go1.18.7 + +#### 1.1-beta11 + +* Add option for custom wireguard reserved bytes +* Fix shadowtls v2 +* Fix h3 dns transport +* Fix copy pipe +* Fix decrypt xplus packet +* Fix v2ray api +* Suppress no network error +* Improve local dns transport + +#### 1.1-beta10 + +* Add [sniff_timeout](/configuration/shared/listen#sniff_timeout) listen option +* Add [custom route](/configuration/inbound/tun#inet4_route_address) support for tun **1** +* Fix interface monitor +* Fix websocket headroom +* Fix uTLS handshake +* Fix ssh outbound +* Fix sniff fragmented quic client hello +* Fix DF for hysteria +* Fix naive overflow +* Check destination before udp connect +* Update uTLS to v1.1.5 +* Update tfo-go to v2.0.2 +* Update fsnotify to v1.6.0 +* Update grpc to v1.50.1 + +*1*: + +The `strict_route` on windows is removed. + +#### 1.0.6 + +* Fix ssh outbound +* Fix sniff fragmented quic client hello +* Fix naive overflow +* Check destination before udp connect + +#### 1.1-beta9 + +* Fix windows route **1** +* Add [v2ray statistics api](/configuration/experimental#v2ray-api-fields) +* Add ShadowTLS v2 support **2** +* Fixes and improvements + +**1**: + +* Fix DNS leak caused by + Windows' [ordinary multihomed DNS resolution behavior](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/dd197552%28v%3Dws.10%29) +* Flush Windows DNS cache when start/close + +**2**: + +See [ShadowTLS inbound](/configuration/inbound/shadowtls#version) +and [ShadowTLS outbound](/configuration/outbound/shadowtls#version) + +#### 1.1-beta8 + +* Fix leaks on close +* Improve websocket writer +* Refine tproxy write back +* Refine 4in6 processing +* Fix shadowsocks plugins +* Fix missing source address from transport connection +* Fix fqdn socks5 outbound connection +* Fix read source address from grpc-go + +#### 1.0.5 + +* Fix missing source address from transport connection +* Fix fqdn socks5 outbound connection +* Fix read source address from grpc-go + +#### 1.1-beta7 + +* Add v2ray mux and XUDP support for VMess inbound +* Add XUDP support for VMess outbound +* Disable DF on direct outbound by default +* Fix bugs in 1.1-beta6 + +#### 1.1-beta6 + +* Add [URLTest outbound](/configuration/outbound/urltest/) +* Fix bugs in 1.1-beta5 + +#### 1.1-beta5 + +* Print tags in version command +* Redirect clash hello to external ui +* Move shadowsocksr implementation to clash +* Make gVisor optional **1** +* Refactor to miekg/dns +* Refactor bind control +* Fix build on go1.18 +* Fix clash store-selected +* Fix close grpc conn +* Fix port rule match logic +* Fix clash api proxy type + +*1*: + +The build tag `no_gvisor` is replaced by `with_gvisor`. + +The default tun stack is changed to system. + +#### 1.0.4 + +* Fix close grpc conn +* Fix port rule match logic +* Fix clash api proxy type + +#### 1.1-beta4 + +* Add internal simple-obfs and v2ray-plugin [Shadowsocks plugins](/configuration/outbound/shadowsocks#plugin) +* Add [ShadowsocksR outbound](/configuration/outbound/shadowsocksr/) +* Add [VLESS outbound and XUDP](/configuration/outbound/vless/) +* Skip wait for hysteria tcp handshake response +* Fix socks4 client +* Fix hysteria inbound +* Fix concurrent write + +#### 1.0.3 + +* Fix socks4 client +* Fix hysteria inbound +* Fix concurrent write + +#### 1.1-beta3 + +* Fix using custom TLS client in http2 client +* Fix bugs in 1.1-beta2 + +#### 1.1-beta2 + +* Add Clash mode and persistence support **1** +* Add TLS ECH and uTLS support for outbound TLS options **2** +* Fix socks4 request +* Fix processing empty dns result + +*1*: + +Switching modes using the Clash API, and `store-selected` are now supported, +see [Experimental](/configuration/experimental/). + +*2*: + +ECH (Encrypted Client Hello) is a TLS extension that allows a client to encrypt the first part of its ClientHello +message, see [TLS#ECH](/configuration/shared/tls#ech). + +uTLS is a fork of "crypto/tls", which provides ClientHello fingerprinting resistance, +see [TLS#uTLS](/configuration/shared/tls#utls). + +#### 1.0.2 + +* Fix socks4 request +* Fix processing empty dns result + +#### 1.1-beta1 + +* Add support for use with android VPNService **1** +* Add tun support for WireGuard outbound **2** +* Add system tun stack **3** +* Add comment filter for config **4** +* Add option for allow optional proxy protocol header +* Add half close for smux +* Set UDP DF by default **5** +* Set default tun mtu to 9000 +* Update gVisor to 20220905.0 + +*1*: + +In previous versions, Android VPN would not work with tun enabled. + +The usage of tun over VPN and VPN over tun is now supported, see [Tun Inbound](/configuration/inbound/tun#auto_route). + +*2*: + +In previous releases, WireGuard outbound support was backed by the lower performance gVisor virtual interface. + +It achieves the same performance as wireguard-go by providing automatic system interface support. + +*3*: + +It does not depend on gVisor and has better performance in some cases. + +It is less compatible and may not be available in some environments. + +*4*: + +Annotated json configuration files are now supported. + +*5*: + +UDP fragmentation is now blocked by default. + +Including shadowsocks-libev, shadowsocks-rust and quic-go all disable segmentation by default. + +See [Dial Fields](/configuration/shared/dial#udp_fragment) +and [Listen Fields](/configuration/shared/listen#udp_fragment). + +#### 1.0.1 + +* Fix match 4in6 address in ip_cidr +* Fix clash api log level format error +* Fix clash api unknown proxy type + +#### 1.0 + +* Fix wireguard reconnect +* Fix naive inbound +* Fix json format error message +* Fix processing vmess termination signal +* Fix hysteria stream error +* Fix listener close when proxyproto failed + +#### 1.0-rc1 + +* Fix write log timestamp +* Fix write zero +* Fix dial parallel in direct outbound +* Fix write trojan udp +* Fix DNS routing +* Add attribute support for geosite +* Update documentation for [Dial Fields](/configuration/shared/dial/) + +#### 1.0-beta3 + +* Add [chained inbound](/configuration/shared/listen#detour) support +* Add process_path rule item +* Add macOS redirect support +* Add ShadowTLS [Inbound](/configuration/inbound/shadowtls/), [Outbound](/configuration/outbound/shadowtls/) + and [Examples](/examples/shadowtls/) +* Fix search android package in non-owner users +* Fix socksaddr type condition +* Fix smux session status +* Refactor inbound and outbound documentation +* Minor fixes + +#### 1.0-beta2 + +* Add strict_route option for [Tun inbound](/configuration/inbound/tun#strict_route) +* Add packetaddr support for [VMess outbound](/configuration/outbound/vmess#packet_addr) +* Add better performing alternative gRPC implementation +* Add [docker image](https://github.com/SagerNet/sing-box/pkgs/container/sing-box) +* Fix sniff override destination + +#### 1.0-beta1 + +* Initial release + +##### 2022/08/26 + +* Fix ipv6 route on linux +* Fix read DNS message + +##### 2022/08/25 + +* Let vmess use zero instead of auto if TLS enabled +* Add trojan fallback for ALPN +* Improve ip_cidr rule +* Fix format bind_address +* Fix http proxy with compressed response +* Fix route connections + +##### 2022/08/24 + +* Fix naive padding +* Fix unix search path +* Fix close non-duplex connections +* Add ACME EAB support +* Fix early close on windows and catch any +* Initial zh-CN document translation + +##### 2022/08/23 + +* Add [V2Ray Transport](/configuration/shared/v2ray-transport/) support for VMess and Trojan +* Allow plain http request in Naive inbound (It can now be used with nginx) +* Add proxy protocol support +* Free memory after start +* Parse X-Forward-For in HTTP requests +* Handle SIGHUP signal + +##### 2022/08/22 + +* Add strategy setting for each [DNS server](/configuration/dns/server/) +* Add bind address to outbound options + +##### 2022/08/21 + +* Add [Tor outbound](/configuration/outbound/tor/) +* Add [SSH outbound](/configuration/outbound/ssh/) + +##### 2022/08/20 + +* Attempt to unwrap ip-in-fqdn socksaddr +* Fix read packages in android 12 +* Fix route on some android devices +* Improve linux process searcher +* Fix write socks5 username password auth request +* Skip bind connection with private destination to interface +* Add [Trojan connection fallback](/configuration/inbound/trojan#fallback) + +##### 2022/08/19 + +* Add Hysteria [Inbound](/configuration/inbound/hysteria/) and [Outbund](/configuration/outbound/hysteria/) +* Add [ACME TLS certificate issuer](/configuration/shared/tls/) +* Allow read config from stdin (-c stdin) +* Update gVisor to 20220815.0 + +##### 2022/08/18 + +* Fix find process with lwip stack +* Fix crash on shadowsocks server +* Fix crash on darwin tun +* Fix write log to file + +##### 2022/08/17 + +* Improve async dns transports + +##### 2022/08/16 + +* Add ip_version (route/dns) rule item +* Add [WireGuard](/configuration/outbound/wireguard/) outbound + +##### 2022/08/15 + +* Add uid, android user and package rules support in [Tun](/configuration/inbound/tun/) routing. + +##### 2022/08/13 + +* Fix dns concurrent write + +##### 2022/08/12 + +* Performance improvements +* Add UoT option for [SOCKS](/configuration/outbound/socks/) outbound + +##### 2022/08/11 + +* Add UoT option for [Shadowsocks](/configuration/outbound/shadowsocks/) outbound, UoT support for all inbounds + +##### 2022/08/10 + +* Add full-featured [Naive](/configuration/inbound/naive/) inbound +* Fix default dns server option [#9] by iKirby + +##### 2022/08/09 + +No changelog before. + +[#9]: https://github.com/SagerNet/sing-box/pull/9 diff --git a/docs/clients/android/features.md b/docs/clients/android/features.md new file mode 100644 index 00000000..b76a6418 --- /dev/null +++ b/docs/clients/android/features.md @@ -0,0 +1,67 @@ +# :material-decagram: Features + +#### UI options + +* Display realtime network speed in the notification + +#### Service + +SFA allows you to run sing-box through ForegroundService or VpnService (when TUN is required). + +#### TUN + +SFA provides an unprivileged TUN implementation through Android VpnService. + +| TUN inbound option | Available | Note | +|-------------------------------|------------------|--------------------| +| `interface_name` | :material-close: | Managed by Android | +| `inet4_address` | :material-check: | / | +| `inet6_address` | :material-check: | / | +| `mtu` | :material-check: | / | +| `gso` | :material-close: | No permission | +| `auto_route` | :material-check: | / | +| `strict_route` | :material-close: | Not implemented | +| `inet4_route_address` | :material-check: | / | +| `inet6_route_address` | :material-check: | / | +| `inet4_route_exclude_address` | :material-check: | / | +| `inet6_route_exclude_address` | :material-check: | / | +| `endpoint_independent_nat` | :material-check: | / | +| `stack` | :material-check: | / | +| `include_interface` | :material-close: | No permission | +| `exclude_interface` | :material-close: | No permission | +| `include_uid` | :material-close: | No permission | +| `exclude_uid` | :material-close: | No permission | +| `include_android_user` | :material-close: | No permission | +| `include_package` | :material-check: | / | +| `exclude_package` | :material-check: | / | +| `platform` | :material-check: | / | + +| Route/DNS rule option | Available | Note | +|-----------------------|------------------|-----------------------------------| +| `process_name` | :material-close: | No permission | +| `process_path` | :material-close: | No permission | +| `process_path_regex` | :material-close: | No permission | +| `package_name` | :material-check: | / | +| `package_name_regex` | :material-check: | / | +| `user` | :material-close: | Use `package_name` instead | +| `user_id` | :material-close: | Use `package_name` instead | +| `wifi_ssid` | :material-check: | Fine location permission required | +| `wifi_bssid` | :material-check: | Fine location permission required | + +### Override + +Overrides profile configuration items with platform-specific values. + +#### Per-app proxy + +SFA allows you to select a list of Android apps that require proxying or bypassing in the graphical interface to +override the `include_package` and `exclude_package` configuration items. + +In particular, the selector also provides the “China apps” scanning feature, providing Chinese users with an excellent +experience to bypass apps that do not require a proxy. Specifically, by scanning China application or SDK +characteristics through dex class path and other means, there will be almost no missed reports. + +### Chore + +* The working directory is located at `/sdcard/Android/data/io.nekohasekai.sfa/files` (External files directory) +* Crash logs is located in `$working_directory/stderr.log` diff --git a/docs/clients/android/index.md b/docs/clients/android/index.md new file mode 100644 index 00000000..19cc9005 --- /dev/null +++ b/docs/clients/android/index.md @@ -0,0 +1,23 @@ +--- +icon: material/android +--- + +# sing-box for Android + +SFA allows users to manage and run local or remote sing-box configuration files, and provides +platform-specific function implementation, such as TUN transparent proxy implementation. + +## :material-graph: Requirements + +* Android 5.0+ + +## :material-download: Download + +* [Play Store](https://play.google.com/store/apps/details?id=io.nekohasekai.sfa) +* [Play Store (Beta)](https://play.google.com/apps/testing/io.nekohasekai.sfa) +* [GitHub Releases](https://github.com/SagerNet/sing-box/releases) +* [F-Droid](https://f-droid.org/packages/io.nekohasekai.sfa/) (Unified signature via reproducible builds) + +## :material-source-repository: Source code + +* [GitHub](https://github.com/SagerNet/sing-box-for-android) diff --git a/docs/clients/apple/features.md b/docs/clients/apple/features.md new file mode 100644 index 00000000..e1f3d7cc --- /dev/null +++ b/docs/clients/apple/features.md @@ -0,0 +1,55 @@ +# :material-decagram: Features + +#### UI options + +* Always On +* Include All Networks (Proxy traffic for LAN and cellular services) +* (Apple tvOS) Import profile from iPhone/iPad + +#### Service + +SFI/SFM/SFT allows you to run sing-box through NetworkExtension with Application Extension or System Extension. + +#### TUN + +SFI/SFM/SFT provides an unprivileged TUN implementation through NetworkExtension. + +| TUN inbound option | Available | Note | +|-------------------------------|-------------------|-------------------| +| `interface_name` | :material-close:️ | Managed by Darwin | +| `inet4_address` | :material-check: | / | +| `inet6_address` | :material-check: | / | +| `mtu` | :material-check: | / | +| `gso` | :material-close: | Not implemented | +| `auto_route` | :material-check: | / | +| `strict_route` | :material-close:️ | Not implemented | +| `inet4_route_address` | :material-check: | / | +| `inet6_route_address` | :material-check: | / | +| `inet4_route_exclude_address` | :material-check: | / | +| `inet6_route_exclude_address` | :material-check: | / | +| `endpoint_independent_nat` | :material-check: | / | +| `stack` | :material-check: | / | +| `include_interface` | :material-close:️ | Not implemented | +| `exclude_interface` | :material-close:️ | Not implemented | +| `include_uid` | :material-close:️ | Not implemented | +| `exclude_uid` | :material-close:️ | Not implemented | +| `include_android_user` | :material-close:️ | Not implemented | +| `include_package` | :material-close:️ | Not implemented | +| `exclude_package` | :material-close:️ | Not implemented | +| `platform` | :material-check: | / | + +| Route/DNS rule option | Available | Note | +|-----------------------|------------------|-----------------------| +| `process_name` | :material-close: | No permission | +| `process_path` | :material-close: | No permission | +| `process_path_regex` | :material-close: | No permission | +| `package_name` | :material-close: | / | +| `package_name_regex` | :material-close: | / | +| `user` | :material-close: | No permission | +| `user_id` | :material-close: | No permission | +| `wifi_ssid` | :material-alert: | Only supported on iOS | +| `wifi_bssid` | :material-alert: | Only supported on iOS | + +### Chore + +* Crash logs is located in `Settings` -> `View Service Log` diff --git a/docs/clients/apple/index.md b/docs/clients/apple/index.md new file mode 100644 index 00000000..ef9807e7 --- /dev/null +++ b/docs/clients/apple/index.md @@ -0,0 +1,41 @@ +--- +icon: material/apple +--- + +# sing-box for Apple platforms + +SFI/SFM/SFT allows users to manage and run local or remote sing-box configuration files, and provides +platform-specific function implementation, such as TUN transparent proxy implementation. + +!!! failure "" + + Due to non-technical reasons, we are temporarily unable to update the sing-box app on the App Store and release the standalone version of the macOS client (TestFlight users are not affected) + +## :material-graph: Requirements + +* iOS 15.0+ / macOS 13.0+ / Apple tvOS 17.0+ +* An Apple account outside of mainland China + +## :material-download: Download + +* ~~[App Store](https://apps.apple.com/app/sing-box-vt/id6673731168)~~ +* TestFlight (Beta) + +TestFlight quota is only available to [sponsors](https://github.com/sponsors/nekohasekai) +(one-time sponsorships are accepted). +Once you donate, you can get an invitation by join our Telegram group for sponsors from [@yet_another_sponsor_bot](https://t.me/yet_another_sponsor_bot) +or sending us your Apple ID [via email](mailto:contact@sagernet.org). + +## ~~:material-file-download: Download (macOS standalone version)~~ + +* ~~[Homebrew Cask](https://formulae.brew.sh/cask/sfm)~~ + +```bash +# brew install sfm +``` + +* ~~[GitHub Releases](https://github.com/SagerNet/sing-box/releases)~~ + +## :material-source-repository: Source code + +* [GitHub](https://github.com/SagerNet/sing-box-for-apple) diff --git a/docs/clients/general.md b/docs/clients/general.md new file mode 100644 index 00000000..4d57c991 --- /dev/null +++ b/docs/clients/general.md @@ -0,0 +1,63 @@ +--- +icon: material/pencil-ruler +--- + +# General + +Describes and explains the functions implemented uniformly by sing-box graphical clients. + +### Profile + +Profile describes a sing-box configuration file and its state. + +#### Local + +* Local Profile represents a local sing-box configuration with minimal state +* The graphical client must provide an editor to modify configuration content + +#### iCloud (on iOS and macOS) + +* iCloud Profile represents a remote sing-box configuration with iCloud as the update source +* The configuration file is stored in the sing-box folder under iCloud +* The graphical client must provide an editor to modify configuration content + +#### Remote + +* Remote Profile represents a remote sing-box configuration with a URL as the update source. +* The graphical client should provide a configuration content viewer +* The graphical client must implement automatic profile update (default interval is 60 minutes) and HTTP Basic + authorization. + +At the same time, the graphical client must provide support for importing remote profiles +through a specific URL Scheme. The URL is defined as follows: + +``` +sing-box://import-remote-profile?url=urlEncodedURL#urlEncodedName +``` + +### Dashboard + +While the sing-box service is running, the graphical client should provide a Dashboard interface to manage the service. + +#### Status + +Dashboard should display status information such as memory, connection, and traffic. + +#### Mode + +Dashboard should provide a Mode selector for switching when the configuration uses at least two `clash_mode` values. + +#### Groups + +When the configuration includes group outbounds (specifically, Selector or URLTest), +the dashboard should provide a Group selector for status display or switching. + +### Chore + +#### Core + +Graphical clients should provide a Core region: + +* Display the current sing-box version +* Provides a button to clean the working directory +* Provides a memory limiter switch \ No newline at end of file diff --git a/docs/clients/index.md b/docs/clients/index.md new file mode 100644 index 00000000..45d2c9a9 --- /dev/null +++ b/docs/clients/index.md @@ -0,0 +1,13 @@ +# :material-cellphone-link: Graphical Clients + +Maintained by Project S to provide a unified experience and platform-specific functionality. + +| Platform | Client | +|---------------------------------------|------------------------------------------| +| :material-android: Android | [sing-box for Android](./android/) | +| :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | +| :material-laptop: Desktop | Working in progress | + +Some third-party projects that claim to use sing-box or use sing-box as a selling point are not listed here. The core +motivation of the maintainers of such projects is to acquire more users, and even though they provide friendly VPN +client features, the code is usually of poor quality and contains ads. diff --git a/docs/clients/index.zh.md b/docs/clients/index.zh.md new file mode 100644 index 00000000..736b42ea --- /dev/null +++ b/docs/clients/index.zh.md @@ -0,0 +1,12 @@ +# :material-cellphone-link: 图形界面客户端 + +由 Project S 维护,提供统一的体验与平台特定的功能。 + +| 平台 | 客户端 | +|---------------------------------------|------------------------------------------| +| :material-android: Android | [sing-box for Android](./android/) | +| :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | +| :material-laptop: Desktop | 施工中 | + +此处没有列出一些声称使用或以 sing-box 为卖点的第三方项目。此类项目维护者的动机是获得更多用户,即使它们提供友好的商业 +VPN 客户端功能, 但代码质量很差且包含广告。 diff --git a/docs/clients/privacy.md b/docs/clients/privacy.md new file mode 100644 index 00000000..61df7102 --- /dev/null +++ b/docs/clients/privacy.md @@ -0,0 +1,18 @@ +--- +icon: material/security +--- + +# Privacy policy + +sing-box and official graphics clients do not collect or share personal data, +and the data generated by the software is always on your device. + +## Android + +The broad package (App) visibility (QUERY_ALL_PACKAGES) permission +is used to provide per-application proxy features for VPN, +sing-box will not collect your app list. + +If your configuration contains `wifi_ssid` or `wifi_bssid` routing rules, +sing-box uses the location permission in the background +to get information about the connected Wi-Fi network to make them work. diff --git a/docs/configuration/certificate/index.md b/docs/configuration/certificate/index.md new file mode 100644 index 00000000..88d73380 --- /dev/null +++ b/docs/configuration/certificate/index.md @@ -0,0 +1,59 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [Chrome Root Store](#store) + +# Certificate + +### Structure + +```json +{ + "store": "", + "certificate": [], + "certificate_path": [], + "certificate_directory_path": [] +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### store + +The default X509 trusted CA certificate list. + +| Type | Description | +|--------------------|----------------------------------------------------------------------------------------------------------------| +| `system` (default) | System trusted CA certificates | +| `mozilla` | [Mozilla Included List](https://wiki.mozilla.org/CA/Included_Certificates) with China CA certificates removed | +| `chrome` | [Chrome Root Store](https://g.co/chrome/root-policy) with China CA certificates removed | +| `none` | Empty list | + +#### certificate + +The certificate line array to trust, in PEM format. + +#### certificate_path + +!!! note "" + + Will be automatically reloaded if file modified. + +The paths to certificates to trust, in PEM format. + +#### certificate_directory_path + +!!! note "" + + Will be automatically reloaded if file modified. + +The directory path to search for certificates to trust,in PEM format. diff --git a/docs/configuration/certificate/index.zh.md b/docs/configuration/certificate/index.zh.md new file mode 100644 index 00000000..77f3fd88 --- /dev/null +++ b/docs/configuration/certificate/index.zh.md @@ -0,0 +1,59 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [Chrome Root Store](#store) + +# 证书 + +### 结构 + +```json +{ + "store": "", + "certificate": [], + "certificate_path": [], + "certificate_directory_path": [] +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 字段 + +#### store + +默认的 X509 受信任 CA 证书列表。 + +| 类型 | 描述 | +|-------------------|--------------------------------------------------------------------------------------------| +| `system`(默认) | 系统受信任的 CA 证书 | +| `mozilla` | [Mozilla 包含列表](https://wiki.mozilla.org/CA/Included_Certificates)(已移除中国 CA 证书) | +| `chrome` | [Chrome Root Store](https://g.co/chrome/root-policy)(已移除中国 CA 证书) | +| `none` | 空列表 | + +#### certificate + +要信任的证书行数组,PEM 格式。 + +#### certificate_path + +!!! note "" + + 文件修改时将自动重新加载。 + +要信任的证书路径,PEM 格式。 + +#### certificate_directory_path + +!!! note "" + + 文件修改时将自动重新加载。 + +搜索要信任的证书的目录路径,PEM 格式。 \ No newline at end of file diff --git a/docs/configuration/dns/fakeip.md b/docs/configuration/dns/fakeip.md new file mode 100644 index 00000000..a0524dc8 --- /dev/null +++ b/docs/configuration/dns/fakeip.md @@ -0,0 +1,31 @@ +--- +icon: material/note-remove +--- + +!!! failure "Removed in sing-box 1.14.0" + + Legacy fake-ip configuration is deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-server-formats). + +### Structure + +```json +{ + "enabled": true, + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" +} +``` + +### Fields + +#### enabled + +Enable FakeIP service. + +#### inet4_range + +IPv4 address range for FakeIP. + +#### inet6_range + +IPv6 address range for FakeIP. diff --git a/docs/configuration/dns/fakeip.zh.md b/docs/configuration/dns/fakeip.zh.md new file mode 100644 index 00000000..1e5eca60 --- /dev/null +++ b/docs/configuration/dns/fakeip.zh.md @@ -0,0 +1,31 @@ +--- +icon: material/note-remove +--- + +!!! failure "已在 sing-box 1.14.0 移除" + + 旧的 fake-ip 配置已在 sing-box 1.12.0 废弃且已在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 + +### 结构 + +```json +{ + "enabled": true, + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" +} +``` + +### 字段 + +#### enabled + +启用 FakeIP 服务。 + +#### inet4_range + +用于 FakeIP 的 IPv4 地址范围。 + +#### inet6_range + +用于 FakeIP 的 IPv6 地址范围。 diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md new file mode 100644 index 00000000..b78a49e7 --- /dev/null +++ b/docs/configuration/dns/index.md @@ -0,0 +1,133 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-delete-clock: [independent_cache](#independent_cache) + :material-plus: [optimistic](#optimistic) + +!!! quote "Changes in sing-box 1.12.0" + + :material-decagram: [servers](#servers) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [cache_capacity](#cache_capacity) + +# DNS + +### Structure + +```json +{ + "dns": { + "servers": [], + "rules": [], + "final": "", + "strategy": "", + "disable_cache": false, + "disable_expire": false, + "independent_cache": false, + "cache_capacity": 0, + "optimistic": false, // or {} + "reverse_mapping": false, + "client_subnet": "", + "fakeip": {} + } +} + +``` + +### Fields + +| Key | Format | +|----------|---------------------------------| +| `server` | List of [DNS Server](./server/) | +| `rules` | List of [DNS Rule](./rule/) | +| `fakeip` | :material-note-remove: [FakeIP](./fakeip/) | + +#### final + +Default dns server tag. + +The first server will be used if empty. + +#### strategy + +Default domain strategy for resolving the domain names. + +One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. + +#### disable_cache + +Disable dns cache. + +Conflict with `optimistic`. + +#### disable_expire + +Disable dns cache expire. + +Conflict with `optimistic`. + +#### independent_cache + +!!! failure "Deprecated in sing-box 1.14.0" + + `independent_cache` is deprecated and will be removed in sing-box 1.14.0, check [Migration](/migration/#migrate-independent-dns-cache). + +Make each DNS server's cache independent for special purposes. If enabled, will slightly degrade performance. + +#### cache_capacity + +!!! question "Since sing-box 1.11.0" + +LRU cache capacity. + +Value less than 1024 will be ignored. + +#### optimistic + +!!! question "Since sing-box 1.14.0" + +Enable optimistic DNS caching. When a cached DNS entry has expired but is still within the timeout window, +the stale response is returned immediately while a background refresh is triggered. + +Conflict with `disable_cache` and `disable_expire`. + +Accepts a boolean or an object. When set to `true`, the default timeout of `3d` is used. + +```json +{ + "enabled": true, + "timeout": "3d" +} +``` + +##### enabled + +Enable optimistic DNS caching. + +##### timeout + +The maximum time an expired cache entry can be served optimistically. + +`3d` is used by default. + +#### reverse_mapping + +Stores a reverse mapping of IP addresses after responding to a DNS query in order to provide domain names when routing. + +Since this process relies on the act of resolving domain names by an application before making a request, it can be +problematic in environments such as macOS, where DNS is proxied and cached by the system. + +#### client_subnet + +!!! question "Since sing-box 1.9.0" + +Append a `edns0-subnet` OPT extra record with the specified IP prefix to every query by default. + +If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. + +Can be overridden by `servers.[].client_subnet` or `rules.[].client_subnet`. diff --git a/docs/configuration/dns/index.zh.md b/docs/configuration/dns/index.zh.md new file mode 100644 index 00000000..ae06b8ab --- /dev/null +++ b/docs/configuration/dns/index.zh.md @@ -0,0 +1,135 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-delete-clock: [independent_cache](#independent_cache) + :material-plus: [optimistic](#optimistic) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-decagram: [servers](#servers) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [cache_capacity](#cache_capacity) + +# DNS + +### 结构 + +```json +{ + "dns": { + "servers": [], + "rules": [], + "final": "", + "strategy": "", + "disable_cache": false, + "disable_expire": false, + "independent_cache": false, + "cache_capacity": 0, + "optimistic": false, // or {} + "reverse_mapping": false, + "client_subnet": "", + "fakeip": {} + } +} + +``` + +### 字段 + +| 键 | 格式 | +|----------|-------------------------| +| `server` | 一组 [DNS 服务器](./server/) | +| `rules` | 一组 [DNS 规则](./rule/) | + +#### final + +默认 DNS 服务器的标签。 + +默认使用第一个服务器。 + +#### strategy + +默认解析域名策略。 + +可选值: `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 + +#### disable_cache + +禁用 DNS 缓存。 + +与 `optimistic` 冲突。 + +#### disable_expire + +禁用 DNS 缓存过期。 + +与 `optimistic` 冲突。 + +#### independent_cache + +!!! failure "已在 sing-box 1.14.0 废弃" + + `independent_cache` 已在 sing-box 1.14.0 废弃,且将在 sing-box 1.16.0 中被移除,参阅[迁移指南](/zh/migration/#迁移-independent-dns-cache)。 + +使每个 DNS 服务器的缓存独立,以满足特殊目的。如果启用,将轻微降低性能。 + +#### cache_capacity + +!!! question "自 sing-box 1.11.0 起" + +LRU 缓存容量。 + +小于 1024 的值将被忽略。 + +#### optimistic + +!!! question "自 sing-box 1.14.0 起" + +启用乐观 DNS 缓存。当缓存的 DNS 条目已过期但仍在超时窗口内时, +立即返回过期的响应,同时在后台触发刷新。 + +与 `disable_cache` 和 `disable_expire` 冲突。 + +接受布尔值或对象。当设置为 `true` 时,使用默认超时 `3d`。 + +```json +{ + "enabled": true, + "timeout": "3d" +} +``` + +##### enabled + +启用乐观 DNS 缓存。 + +##### timeout + +过期缓存条目可被乐观提供的最长时间。 + +默认使用 `3d`。 + +#### reverse_mapping + +在响应 DNS 查询后存储 IP 地址的反向映射以为路由目的提供域名。 + +由于此过程依赖于应用程序在发出请求之前解析域名的行为,因此在 macOS 等 DNS 由系统代理和缓存的环境中可能会出现问题。 + +#### client_subnet + +!!! question "自 sing-box 1.9.0 起" + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +可以被 `servers.[].client_subnet` 或 `rules.[].client_subnet` 覆盖。 + +#### fakeip :material-note-remove: + +[FakeIP](./fakeip/) 设置。 diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md new file mode 100644 index 00000000..b0785b77 --- /dev/null +++ b/docs/configuration/dns/rule.md @@ -0,0 +1,699 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + :material-plus: [match_response](#match_response) + :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-plus: [response_rcode](#response_rcode) + :material-plus: [response_answer](#response_answer) + :material-plus: [response_ns](#response_ns) + :material-plus: [response_extra](#response_extra) + :material-plus: [package_name_regex](#package_name_regex) + :material-alert: [ip_version](#ip_version) + :material-alert: [query_type](#query_type) + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [interface_address](#interface_address) + :material-plus: [network_interface_address](#network_interface_address) + :material-plus: [default_interface_address](#default_interface_address) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [ip_accept_any](#ip_accept_any) + :material-delete-clock: [outbound](#outbound) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [action](#action) + :material-alert: [server](#server) + :material-alert: [disable_cache](#disable_cache) + :material-alert: [rewrite_ttl](#rewrite_ttl) + :material-alert: [client_subnet](#client_subnet) + :material-plus: [network_type](#network_type) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) + +!!! quote "Changes in sing-box 1.10.0" + + :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source) + :material-plus: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-plus: [process_path_regex](#process_path_regex) + +!!! quote "Changes in sing-box 1.9.0" + + :material-plus: [geoip](#geoip) + :material-plus: [ip_cidr](#ip_cidr) + :material-plus: [ip_is_private](#ip_is_private) + :material-plus: [client_subnet](#client_subnet) + :material-plus: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [rule_set](#rule_set) + :material-plus: [source_ip_is_private](#source_ip_is_private) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + +### Structure + +```json +{ + "dns": { + "rules": [ + { + "inbound": [ + "mixed-in" + ], + "ip_version": 6, + "query_type": [ + "A", + "HTTPS", + 32768 + ], + "network": "tcp", + "auth_user": [ + "usera", + "userb" + ], + "protocol": [ + "tls", + "http", + "quic" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "source_ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "source_ip_is_private": false, + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "process_path_regex": [ + "^/usr/bin/.+" + ], + "package_name": [ + "com.termux" + ], + "package_name_regex": [ + "^com\\.termux.*" + ], + "user": [ + "sekai" + ], + "user_id": [ + 1000 + ], + "clash_mode": "direct", + "network_type": [ + "wifi" + ], + "network_is_expensive": false, + "network_is_constrained": false, + "interface_address": { + "en0": [ + "2000::/3" + ] + }, + "network_interface_address": { + "wifi": [ + "2000::/3" + ] + }, + "default_interface_address": [ + "2000::/3" + ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], + "rule_set_ip_cidr_match_source": false, + "match_response": false, + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_is_private": false, + "ip_accept_any": false, + "response_rcode": "", + "response_answer": [], + "response_ns": [], + "response_extra": [], + "invert": false, + "outbound": [ + "direct" + ], + "action": "route", + "server": "local", + + // Deprecated + + "rule_set_ip_cidr_accept_empty": false, + "rule_set_ipcidr_match_source": false, + "geosite": [ + "cn" + ], + "source_geoip": [ + "private" + ], + "geoip": [ + "cn" + ] + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "action": "route", + "server": "local" + } + ] + } +} + +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Default Fields + +!!! note "" + + The default rule uses the following matching logic: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `geosite`) && + (`port` || `port_range`) && + (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && + (`source_port` || `source_port_range`) && + `other fields` + + Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + +#### inbound + +Tags of [Inbound](/configuration/inbound/). + +#### ip_version + +!!! quote "Changes in sing-box 1.14.0" + + This field now also applies when a DNS rule is matched from an internal + domain resolution that does not target a specific DNS server, such as a + [`resolve`](../../route/rule_action/#resolve) route rule action without a + `server` set. In earlier versions, only DNS queries received from a + client evaluated this field. See + [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules) + for the full list. + + Setting this field makes the DNS rule incompatible in the same DNS + configuration with Legacy Address Filter Fields in DNS rules, the Legacy + `strategy` DNS rule action option, and the Legacy + `rule_set_ip_cidr_accept_empty` DNS rule item. To combine with + address-based filtering, use the [`evaluate`](../rule_action/#evaluate) + action and [`match_response`](#match_response). + +4 (A DNS query) or 6 (AAAA DNS query). + +Not limited if empty. + +#### query_type + +!!! quote "Changes in sing-box 1.14.0" + + This field now also applies when a DNS rule is matched from an internal + domain resolution that does not target a specific DNS server, such as a + [`resolve`](../../route/rule_action/#resolve) route rule action without a + `server` set. In earlier versions, only DNS queries received from a + client evaluated this field. See + [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules) + for the full list. + + Setting this field makes the DNS rule incompatible in the same DNS + configuration with Legacy Address Filter Fields in DNS rules, the Legacy + `strategy` DNS rule action option, and the Legacy + `rule_set_ip_cidr_accept_empty` DNS rule item. To combine with + address-based filtering, use the [`evaluate`](../rule_action/#evaluate) + action and [`match_response`](#match_response). + +DNS query type. Values can be integers or type name strings. + +#### network + +`tcp` or `udp`. + +#### auth_user + +Username, see each inbound for details. + +#### protocol + +Sniffed protocol, see [Sniff](/configuration/route/sniff/) for details. + +#### domain + +Match full domain. + +#### domain_suffix + +Match domain suffix. + +#### domain_keyword + +Match domain using keyword. + +#### domain_regex + +Match domain using regular expression. + +#### geosite + +!!! failure "Deprecated in sing-box 1.8.0" + + Geosite is deprecated and will be removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geosite-to-rule-sets). + +Match geosite. + +#### source_geoip + +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and will be removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geoip-to-rule-sets). + +Match source geoip. + +#### source_ip_cidr + +Match source IP CIDR. + +#### source_ip_is_private + +!!! question "Since sing-box 1.8.0" + +Match non-public source IP. + +#### source_port + +Match source port. + +#### source_port_range + +Match source port range. + +#### port + +Match port. + +#### port_range + +Match port range. + +#### process_name + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process name. + +#### process_path + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path. + +#### process_path_regex + +!!! question "Since sing-box 1.10.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path using regular expression. + +#### package_name + +Match android package name. + +#### package_name_regex + +!!! question "Since sing-box 1.14.0" + +Match android package name using regular expression. + +#### user + +!!! quote "" + + Only supported on Linux. + +Match user name. + +#### user_id + +!!! quote "" + + Only supported on Linux. + +Match user id. + +#### clash_mode + +Match Clash mode. + +#### network_type + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match network type. + +Available values: `wifi`, `cellular`, `ethernet` and `other`. + +#### network_is_expensive + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match if network is considered Metered (on Android) or considered expensive, +such as Cellular or a Personal Hotspot (on Apple platforms). + +#### network_is_constrained + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Apple platforms. + +Match if network is in Low Data Mode. + +#### interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match interface address. + +#### network_interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Matches network interface (same values as `network_type`) address. + +#### default_interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match default interface address. + +#### source_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device MAC address. + +#### source_hostname + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device hostname from DHCP leases. + +#### wifi_ssid + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms, or on Linux. + +Match WiFi SSID. + +#### wifi_bssid + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms, or on Linux. + +Match WiFi BSSID. + +#### rule_set + +!!! question "Since sing-box 1.8.0" + +Match [rule-set](/configuration/route/#rule_set). + +#### rule_set_ipcidr_match_source + +!!! question "Since sing-box 1.9.0" + +!!! failure "Deprecated in sing-box 1.10.0" + + `rule_set_ipcidr_match_source` is renamed to `rule_set_ip_cidr_match_source` and will be remove in sing-box 1.11.0. + +Make `ip_cidr` rule items in rule-sets match the source IP. + +#### rule_set_ip_cidr_match_source + +!!! question "Since sing-box 1.10.0" + +Make `ip_cidr` rule items in rule-sets match the source IP. + +#### match_response + +!!! question "Since sing-box 1.14.0" + +Enable response-based matching. When enabled, this rule matches against the evaluated response +(set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) +instead of only matching the original query. + +The evaluated response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. + +Required for Response Match Fields (`response_rcode`, `response_answer`, `response_ns`, `response_extra`). +Also required for `ip_cidr`, `ip_is_private`, and `ip_accept_any` when used with `evaluate` or Response Match Fields. + +#### ip_accept_any + +!!! question "Since sing-box 1.12.0" + +Match when the DNS query response contains at least one address. + +#### invert + +Invert match result. + +#### outbound + +!!! failure "Deprecated in sing-box 1.12.0" + + `outbound` rule items are deprecated and will be removed in sing-box 1.14.0, check [Migration](/migration/#migrate-outbound-dns-rule-items-to-domain-resolver). + +Match outbound. + +`any` can be used as a value to match any outbound. + +#### action + +==Required== + +See [DNS Rule Actions](../rule_action/) for details. + +#### server + +!!! failure "Deprecated in sing-box 1.11.0" + + Moved to [DNS Rule Action](../rule_action#route). + +#### disable_cache + +!!! failure "Deprecated in sing-box 1.11.0" + + Moved to [DNS Rule Action](../rule_action#route). + +#### rewrite_ttl + +!!! failure "Deprecated in sing-box 1.11.0" + + Moved to [DNS Rule Action](../rule_action#route). + +#### client_subnet + +!!! failure "Deprecated in sing-box 1.11.0" + + Moved to [DNS Rule Action](../rule_action#route). + +### Legacy Address Filter Fields + +!!! failure "Deprecated in sing-box 1.14.0" + + Legacy Address Filter Fields are deprecated and will be removed in sing-box 1.16.0, + check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + +Only takes effect for address requests (A/AAAA/HTTPS). When the query results do not match the address filtering rule items, the current rule will be skipped. + +!!! info "" + + `ip_cidr` items in included rule-sets also takes effect as an address filtering field. + +!!! note "" + + Enable `experimental.cache_file.store_rdrc` to cache results. + +#### geoip + +!!! failure "Removed in sing-box 1.12.0" + + GeoIP is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geoip-to-rule-sets). + +Match GeoIP with query response. + +#### ip_cidr + +!!! question "Since sing-box 1.9.0" + +Match IP CIDR with query response. + +As a Legacy Address Filter Field, deprecated. Use with `match_response` instead, +check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + +#### ip_is_private + +!!! question "Since sing-box 1.9.0" + +Match private IP with query response. + +As a Legacy Address Filter Field, deprecated. Use with `match_response` instead, +check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + +#### rule_set_ip_cidr_accept_empty + +!!! question "Since sing-box 1.10.0" + +!!! failure "Deprecated in sing-box 1.14.0" + + `rule_set_ip_cidr_accept_empty` is deprecated and will be removed in sing-box 1.16.0, + check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + +Make `ip_cidr` rules in rule-sets accept empty query response. + +### Response Match Fields + +!!! question "Since sing-box 1.14.0" + +Match fields for the evaluated response. Require `match_response` to be set to `true` +and a preceding rule with [`evaluate`](/configuration/dns/rule_action/#evaluate) action to populate the response. + +That evaluated response may also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. + +#### response_rcode + +Match DNS response code. + +Accepted values are the same as in the [predefined action rcode](/configuration/dns/rule_action/#rcode). + +#### response_answer + +Match DNS answer records. + +Record format is the same as in [predefined action answer](/configuration/dns/rule_action/#answer). + +#### response_ns + +Match DNS name server records. + +Record format is the same as in [predefined action ns](/configuration/dns/rule_action/#ns). + +#### response_extra + +Match DNS extra records. + +Record format is the same as in [predefined action extra](/configuration/dns/rule_action/#extra). + +### Logical Fields + +#### type + +`logical` + +#### mode + +`and` or `or` + +#### rules + +Included rules. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md new file mode 100644 index 00000000..cc0a3037 --- /dev/null +++ b/docs/configuration/dns/rule.zh.md @@ -0,0 +1,694 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + :material-plus: [match_response](#match_response) + :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-plus: [response_rcode](#response_rcode) + :material-plus: [response_answer](#response_answer) + :material-plus: [response_ns](#response_ns) + :material-plus: [response_extra](#response_extra) + :material-plus: [package_name_regex](#package_name_regex) + :material-alert: [ip_version](#ip_version) + :material-alert: [query_type](#query_type) + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [interface_address](#interface_address) + :material-plus: [network_interface_address](#network_interface_address) + :material-plus: [default_interface_address](#default_interface_address) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [ip_accept_any](#ip_accept_any) + :material-delete-clock: [outbound](#outbound) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [action](#action) + :material-alert: [server](#server) + :material-alert: [disable_cache](#disable_cache) + :material-alert: [rewrite_ttl](#rewrite_ttl) + :material-alert: [client_subnet](#client_subnet) + :material-plus: [network_type](#network_type) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) + +!!! quote "sing-box 1.10.0 中的更改" + + :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source) + :material-plus: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-plus: [process_path_regex](#process_path_regex) + +!!! quote "sing-box 1.9.0 中的更改" + + :material-plus: [geoip](#geoip) + :material-plus: [ip_cidr](#ip_cidr) + :material-plus: [ip_is_private](#ip_is_private) + :material-plus: [client_subnet](#client_subnet) + :material-plus: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [rule_set](#rule_set) + :material-plus: [source_ip_is_private](#source_ip_is_private) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + +### 结构 + +```json +{ + "dns": { + "rules": [ + { + "inbound": [ + "mixed-in" + ], + "ip_version": 6, + "query_type": [ + "A", + "HTTPS", + 32768 + ], + "network": "tcp", + "auth_user": [ + "usera", + "userb" + ], + "protocol": [ + "tls", + "http", + "quic" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "source_ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "source_ip_is_private": false, + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "process_path_regex": [ + "^/usr/bin/.+" + ], + "package_name": [ + "com.termux" + ], + "package_name_regex": [ + "^com\\.termux.*" + ], + "user": [ + "sekai" + ], + "user_id": [ + 1000 + ], + "clash_mode": "direct", + "network_type": [ + "wifi" + ], + "network_is_expensive": false, + "network_is_constrained": false, + "interface_address": { + "en0": [ + "2000::/3" + ] + }, + "network_interface_address": { + "wifi": [ + "2000::/3" + ] + }, + "default_interface_address": [ + "2000::/3" + ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], + "rule_set_ip_cidr_match_source": false, + "match_response": false, + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_is_private": false, + "ip_accept_any": false, + "response_rcode": "", + "response_answer": [], + "response_ns": [], + "response_extra": [], + "invert": false, + "outbound": [ + "direct" + ], + "action": "route", + "server": "local", + + // 已弃用 + + "rule_set_ip_cidr_accept_empty": false, + "rule_set_ipcidr_match_source": false, + "geosite": [ + "cn" + ], + "source_geoip": [ + "private" + ], + "geoip": [ + "cn" + ] + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "action": "route", + "server": "local" + } + ] + } +} + +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 默认字段 + +!!! note "" + + 默认规则使用以下匹配逻辑: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `geosite`) && + (`port` || `port_range`) && + (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && + (`source_port` || `source_port_range`) && + `other fields` + + 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + +#### inbound + +[入站](/zh/configuration/inbound/) 标签. + +#### ip_version + +!!! quote "sing-box 1.14.0 中的更改" + + 此字段现在也会在 DNS 规则被未指定具体 DNS 服务器的内部域名解析匹配时生效, + 例如未设置 `server` 的 [`resolve`](../../route/rule_action/#resolve) 路由规则动作。 + 此前只有来自客户端的 DNS 查询才会评估此字段。完整列表参阅 + [迁移指南](/zh/migration/#dns-规则中的-ip_version-和-query_type-行为更改)。 + + 在 DNS 规则中设置此字段后,该 DNS 规则在同一 DNS 配置中不能与 + 旧版地址筛选字段 (DNS 规则)、旧版 DNS 规则动作 `strategy` 选项, + 或旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项共存。如需与 + 基于地址的筛选组合,请使用 [`evaluate`](../rule_action/#evaluate) 动作和 + [`match_response`](#match_response)。 + +4 (A DNS 查询) 或 6 (AAAA DNS 查询)。 + +默认不限制。 + +#### query_type + +!!! quote "sing-box 1.14.0 中的更改" + + 此字段现在也会在 DNS 规则被未指定具体 DNS 服务器的内部域名解析匹配时生效, + 例如未设置 `server` 的 [`resolve`](../../route/rule_action/#resolve) 路由规则动作。 + 此前只有来自客户端的 DNS 查询才会评估此字段。完整列表参阅 + [迁移指南](/zh/migration/#dns-规则中的-ip_version-和-query_type-行为更改)。 + + 在 DNS 规则中设置此字段后,该 DNS 规则在同一 DNS 配置中不能与 + 旧版地址筛选字段 (DNS 规则)、旧版 DNS 规则动作 `strategy` 选项, + 或旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项共存。如需与 + 基于地址的筛选组合,请使用 [`evaluate`](../rule_action/#evaluate) 动作和 + [`match_response`](#match_response)。 + +DNS 查询类型。值可以为整数或者类型名称字符串。 + +#### network + +`tcp` 或 `udp`。 + +#### auth_user + +认证用户名,参阅入站设置。 + +#### protocol + +探测到的协议, 参阅 [协议探测](/zh/configuration/route/sniff/)。 + +#### domain + +匹配完整域名。 + +#### domain_suffix + +匹配域名后缀。 + +#### domain_keyword + +匹配域名关键字。 + +#### domain_regex + +匹配域名正则表达式。 + +#### geosite + +!!! failure "已在 sing-box 1.12.0 中被移除" + + GeoSite 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 + +匹配 Geosite。 + +#### source_geoip + +!!! failure "已在 sing-box 1.12.0 中被移除" + + GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 + +匹配源 GeoIP。 + +#### source_ip_cidr + +匹配源 IP CIDR。 + +#### source_ip_is_private + +!!! question "自 sing-box 1.8.0 起" + +匹配非公开源 IP。 + +#### source_port + +匹配源端口。 + +#### source_port_range + +匹配源端口范围。 + +#### port + +匹配端口。 + +#### port_range + +匹配端口范围。 + +#### process_name + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配进程名称。 + +#### process_path + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配进程路径。 + +#### process_path_regex + +!!! question "自 sing-box 1.10.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +使用正则表达式匹配进程路径。 + +#### package_name + +匹配 Android 应用包名。 + +#### package_name_regex + +!!! question "自 sing-box 1.14.0 起" + +使用正则表达式匹配 Android 应用包名。 + +#### user + +!!! quote "" + + 仅支持 Linux。 + +匹配用户名。 + +#### user_id + +!!! quote "" + + 仅支持 Linux。 + +匹配用户 ID。 + +#### clash_mode + +匹配 Clash 模式。 + +#### network_type + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配网络类型。 + +Available values: `wifi`, `cellular`, `ethernet` and `other`. + +#### network_is_expensive + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配如果网络被视为计费 (在 Android) 或被视为昂贵, +像蜂窝网络或个人热点 (在 Apple 平台)。 + +#### network_is_constrained + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Apple 平台图形客户端中支持。 + +匹配如果网络在低数据模式下。 + +#### interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配接口地址。 + +#### network_interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配网络接口(可用值同 `network_type`)地址。 + +#### default_interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配默认接口地址。 + +#### source_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备 MAC 地址。 + +#### source_hostname + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备从 DHCP 租约获取的主机名。 + +#### wifi_ssid + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。 + +匹配 WiFi SSID。 + +#### wifi_bssid + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。 + +匹配 WiFi BSSID。 + +#### rule_set + +!!! question "自 sing-box 1.8.0 起" + +匹配[规则集](/zh/configuration/route/#rule_set)。 + +#### rule_set_ipcidr_match_source + +!!! question "自 sing-box 1.9.0 起" + +!!! failure "已在 sing-box 1.10.0 废弃" + + `rule_set_ipcidr_match_source` 已重命名为 `rule_set_ip_cidr_match_source` 且将在 sing-box 1.11.0 中被移除。 + +使规则集中的 `ip_cidr` 规则匹配源 IP。 + +#### rule_set_ip_cidr_match_source + +!!! question "自 sing-box 1.10.0 起" + +使规则集中的 `ip_cidr` 规则匹配源 IP。 + +#### match_response + +!!! question "自 sing-box 1.14.0 起" + +启用响应匹配。启用后,此规则将匹配已评估的响应(由前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作设置),而不仅是匹配原始查询。 + +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 + +响应匹配字段(`response_rcode`、`response_answer`、`response_ns`、`response_extra`)需要此选项。 +当与 `evaluate` 或响应匹配字段一起使用时,`ip_cidr`、`ip_is_private` 和 `ip_accept_any` 也需要此选项。 + +#### ip_accept_any + +!!! question "自 sing-box 1.12.0 起" + +当 DNS 查询响应包含至少一个地址时匹配。 + +#### invert + +反选匹配结果。 + +#### outbound + +!!! failure "已在 sing-box 1.12.0 废弃" + + `outbound` 规则项已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-outbound-dns-规则项到域解析选项)。 + +匹配出站。 + +`any` 可作为值用于匹配任意出站。 + +#### action + +==必填== + +参阅 [规则动作](../rule_action/)。 + +#### server + +!!! failure "已在 sing-box 1.11.0 废弃" + + 已移动到 [DNS 规则动作](../rule_action#route). + +#### disable_cache + +!!! failure "已在 sing-box 1.11.0 废弃" + + 已移动到 [DNS 规则动作](../rule_action#route). + +#### rewrite_ttl + +!!! failure "已在 sing-box 1.11.0 废弃" + + 已移动到 [DNS 规则动作](../rule_action#route). + +#### client_subnet + +!!! failure "已在 sing-box 1.11.0 废弃" + + 已移动到 [DNS 规则动作](../rule_action#route). + +### 旧版地址筛选字段 + +!!! failure "已在 sing-box 1.14.0 废弃" + + 旧版地址筛选字段已废弃,且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +仅对地址请求 (A/AAAA/HTTPS) 生效。 当查询结果与地址筛选规则项不匹配时,将跳过当前规则。 + +!!! info "" + + 引用的规则集中的 `ip_cidr` 项也作为地址筛选字段生效。 + +!!! note "" + + 启用 `experimental.cache_file.store_rdrc` 以缓存结果。 + +#### geoip + +!!! failure "已在 sing-box 1.12.0 中被移除" + + GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 + + +与查询响应匹配 GeoIP。 + +#### ip_cidr + +!!! question "自 sing-box 1.9.0 起" + +与查询响应匹配 IP CIDR。 + +作为旧版地址筛选字段已废弃。请改为配合 `match_response` 使用, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +#### ip_is_private + +!!! question "自 sing-box 1.9.0 起" + +与查询响应匹配非公开 IP。 + +作为旧版地址筛选字段已废弃。请改为配合 `match_response` 使用, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +#### rule_set_ip_cidr_accept_empty + +!!! question "自 sing-box 1.10.0 起" + +!!! failure "已在 sing-box 1.14.0 废弃" + + `rule_set_ip_cidr_accept_empty` 已废弃且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +使规则集中的 `ip_cidr` 规则接受空查询响应。 + +### 响应匹配字段 + +!!! question "自 sing-box 1.14.0 起" + +已评估的响应的匹配字段。需要将 `match_response` 设为 `true`, +且需要前序规则使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作来填充响应。 + +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 + +#### response_rcode + +匹配 DNS 响应码。 + +接受的值与 [predefined 动作 rcode](/zh/configuration/dns/rule_action/#rcode) 中相同。 + +#### response_answer + +匹配 DNS 应答记录。 + +记录格式与 [predefined 动作 answer](/zh/configuration/dns/rule_action/#answer) 中相同。 + +#### response_ns + +匹配 DNS 名称服务器记录。 + +记录格式与 [predefined 动作 ns](/zh/configuration/dns/rule_action/#ns) 中相同。 + +#### response_extra + +匹配 DNS 额外记录。 + +记录格式与 [predefined 动作 extra](/zh/configuration/dns/rule_action/#extra) 中相同。 + +### 逻辑字段 + +#### type + +`logical` + +#### mode + +==必填== + +`and` 或 `or` + +#### rules + +==必填== + +包括的规则。 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md new file mode 100644 index 00000000..e5a99be3 --- /dev/null +++ b/docs/configuration/dns/rule_action.md @@ -0,0 +1,231 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-delete-clock: [strategy](#strategy) + :material-plus: [evaluate](#evaluate) + :material-plus: [respond](#respond) + :material-plus: [disable_optimistic_cache](#disable_optimistic_cache) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [strategy](#strategy) + :material-plus: [predefined](#predefined) + +!!! question "Since sing-box 1.11.0" + +### route + +```json +{ + "action": "route", // default + "server": "", + "strategy": "", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`route` inherits the classic rule behavior of routing DNS requests to the specified server. + +#### server + +==Required== + +Tag of target server. + +#### strategy + +!!! question "Since sing-box 1.12.0" + +!!! failure "Deprecated in sing-box 1.14.0" + + `strategy` is deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0. + +Set domain strategy for this query. + +One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. + +#### disable_cache + +Disable cache and save cache in this query. + +#### disable_optimistic_cache + +!!! question "Since sing-box 1.14.0" + +Disable optimistic DNS caching in this query. + +#### rewrite_ttl + +Rewrite TTL in DNS responses. + +#### client_subnet + +Append a `edns0-subnet` OPT extra record with the specified IP prefix to every query by default. + +If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. + +Will override `dns.client_subnet`. + +### evaluate + +!!! question "Since sing-box 1.14.0" + +```json +{ + "action": "evaluate", + "server": "", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`evaluate` sends a DNS query to the specified server and saves the evaluated response for subsequent rules +to match against using [`match_response`](/configuration/dns/rule/#match_response) and response fields. +Unlike `route`, it does **not** terminate rule evaluation. + +Only allowed on top-level DNS rules (not inside logical sub-rules). +Rules that use [`match_response`](/configuration/dns/rule/#match_response) or Response Match Fields +require a preceding top-level rule with `evaluate` action. A rule's own `evaluate` action +does not satisfy this requirement, because matching happens before the action runs. + +#### server + +==Required== + +Tag of target server. + +#### disable_cache + +Disable cache and save cache in this query. + +#### disable_optimistic_cache + +!!! question "Since sing-box 1.14.0" + +Disable optimistic DNS caching in this query. + +#### rewrite_ttl + +Rewrite TTL in DNS responses. + +#### client_subnet + +Append a `edns0-subnet` OPT extra record with the specified IP prefix to every query by default. + +If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. + +Will override `dns.client_subnet`. + +### respond + +!!! question "Since sing-box 1.14.0" + +```json +{ + "action": "respond" +} +``` + +`respond` terminates rule evaluation and returns the evaluated response from a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action. + +This action does not send a new DNS query and has no extra options. + +Only allowed after a preceding top-level `evaluate` rule. If the action is reached without an evaluated response at runtime, the request fails with an error instead of falling through to later rules. + +### route-options + +```json +{ + "action": "route-options", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`route-options` set options for routing. + +### reject + +```json +{ + "action": "reject", + "method": "", + "no_drop": false +} +``` + +`reject` reject DNS requests. + +#### method + +- `default`: Reply with REFUSED. +- `drop`: Drop the request. + +`default` will be used by default. + +#### no_drop + +If not enabled, `method` will be temporarily overwritten to `drop` after 50 triggers in 30s. + +Not available when `method` is set to drop. + +### predefined + +!!! question "Since sing-box 1.12.0" + +```json +{ + "action": "predefined", + "rcode": "", + "answer": [], + "ns": [], + "extra": [] +} +``` + +`predefined` responds with predefined DNS records. + +#### rcode + +The response code. + +| Value | Value in the legacy rcode server | Description | +|------------|----------------------------------|-----------------| +| `NOERROR` | `success` | Ok | +| `FORMERR` | `format_error` | Bad request | +| `SERVFAIL` | `server_failure` | Server failure | +| `NXDOMAIN` | `name_error` | Not found | +| `NOTIMP` | `not_implemented` | Not implemented | +| `REFUSED` | `refused` | Refused | + +`NOERROR` will be used by default. + +#### answer + +List of text DNS record to respond as answers. + +Examples: + +| Record Type | Example | +|-------------|-------------------------------| +| `A` | `localhost. IN A 127.0.0.1` | +| `AAAA` | `localhost. IN AAAA ::1` | +| `TXT` | `localhost. IN TXT \"Hello\"` | + +#### ns + +List of text DNS record to respond as name servers. + +#### extra + +List of text DNS record to respond as extra records. diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md new file mode 100644 index 00000000..24179977 --- /dev/null +++ b/docs/configuration/dns/rule_action.zh.md @@ -0,0 +1,229 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-delete-clock: [strategy](#strategy) + :material-plus: [evaluate](#evaluate) + :material-plus: [respond](#respond) + :material-plus: [disable_optimistic_cache](#disable_optimistic_cache) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [strategy](#strategy) + :material-plus: [predefined](#predefined) + +!!! question "自 sing-box 1.11.0 起" + +### route + +```json +{ + "action": "route", // 默认 + "server": "", + "strategy": "", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`route` 继承了将 DNS 请求 路由到指定服务器的经典规则动作。 + +#### server + +==必填== + +目标 DNS 服务器的标签。 + +#### strategy + +!!! question "自 sing-box 1.12.0 起" + +!!! failure "已在 sing-box 1.14.0 废弃" + + `strategy` 已在 sing-box 1.14.0 废弃,且将在 sing-box 1.16.0 中被移除。 + +为此查询设置域名策略。 + +可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 + +#### disable_cache + +在此查询中禁用缓存。 + +#### disable_optimistic_cache + +!!! question "自 sing-box 1.14.0 起" + +在此查询中禁用乐观 DNS 缓存。 + +#### rewrite_ttl + +重写 DNS 回应中的 TTL。 + +#### client_subnet + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +将覆盖 `dns.client_subnet`. + +### evaluate + +!!! question "自 sing-box 1.14.0 起" + +```json +{ + "action": "evaluate", + "server": "", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`evaluate` 向指定服务器发送 DNS 查询并保存已评估的响应,供后续规则通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 和响应字段进行匹配。与 `route` 不同,它**不会**终止规则评估。 + +仅允许在顶层 DNS 规则中使用(不可在逻辑子规则内部使用)。 +使用 [`match_response`](/zh/configuration/dns/rule/#match_response) 或响应匹配字段的规则, +需要位于更早的顶层 `evaluate` 规则之后。规则自身的 `evaluate` 动作不能满足这个条件, +因为匹配发生在动作执行之前。 + +#### server + +==必填== + +目标 DNS 服务器的标签。 + +#### disable_cache + +在此查询中禁用缓存。 + +#### disable_optimistic_cache + +!!! question "自 sing-box 1.14.0 起" + +在此查询中禁用乐观 DNS 缓存。 + +#### rewrite_ttl + +重写 DNS 回应中的 TTL。 + +#### client_subnet + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +将覆盖 `dns.client_subnet`. + +### respond + +!!! question "自 sing-box 1.14.0 起" + +```json +{ + "action": "respond" +} +``` + +`respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的已评估的响应。 + +此动作不会发起新的 DNS 查询,也没有额外选项。 + +只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已评估的响应,则请求会直接返回错误,而不是继续匹配后续规则。 + +### route-options + +```json +{ + "action": "route-options", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`route-options` 为路由设置选项。 + +### reject + +```json +{ + "action": "reject", + "method": "", + "no_drop": false +} +``` + +`reject` 拒绝 DNS 请求。 + +#### method + +- `default`: 返回 REFUSED。 +- `drop`: 丢弃请求。 + +默认使用 `default`。 + +#### no_drop + +如果未启用,则 30 秒内触发 50 次后,`method` 将被暂时覆盖为 `drop`。 + +当 `method` 设为 `drop` 时不可用。 + +### predefined + +!!! question "自 sing-box 1.12.0 起" + +```json +{ + "action": "predefined", + "rcode": "", + "answer": [], + "ns": [], + "extra": [] +} +``` + +`predefined` 以预定义的 DNS 记录响应。 + +#### rcode + +响应码。 + +| 值 | 旧 rcode DNS 服务器中的值 | 描述 | +|------------|--------------------|-----------------| +| `NOERROR` | `success` | Ok | +| `FORMERR` | `format_error` | Bad request | +| `SERVFAIL` | `server_failure` | Server failure | +| `NXDOMAIN` | `name_error` | Not found | +| `NOTIMP` | `not_implemented` | Not implemented | +| `REFUSED` | `refused` | Refused | + +默认使用 `NOERROR`。 + +#### answer + +用于作为回答响应的文本 DNS 记录列表。 + +例子: + +| 记录类型 | 例子 | +|--------|-------------------------------| +| `A` | `localhost. IN A 127.0.0.1` | +| `AAAA` | `localhost. IN AAAA ::1` | +| `TXT` | `localhost. IN TXT \"Hello\"` | + +#### ns + +用于作为名称服务器响应的文本 DNS 记录列表。 + +#### extra + +用于作为额外记录响应的文本 DNS 记录列表。 diff --git a/docs/configuration/dns/server/dhcp.md b/docs/configuration/dns/server/dhcp.md new file mode 100644 index 00000000..b26da2a5 --- /dev/null +++ b/docs/configuration/dns/server/dhcp.md @@ -0,0 +1,38 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# DHCP + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "dhcp", + "tag": "", + + "interface": "", + + // Dial Fields + } + ] + } +} +``` + +### Fields + +#### interface + +Interface name to listen on. + +Tge default interface will be used by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/dhcp.zh.md b/docs/configuration/dns/server/dhcp.zh.md new file mode 100644 index 00000000..2a67a7a3 --- /dev/null +++ b/docs/configuration/dns/server/dhcp.zh.md @@ -0,0 +1,38 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# DHCP + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "dhcp", + "tag": "", + + "interface": "", + + // 拨号字段 + } + ] + } +} +``` + +### 字段 + +#### interface + +要监听的网络接口名称。 + +默认使用默认接口。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/fakeip.md b/docs/configuration/dns/server/fakeip.md new file mode 100644 index 00000000..6d2bba43 --- /dev/null +++ b/docs/configuration/dns/server/fakeip.md @@ -0,0 +1,35 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# Fake IP + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "fakeip", + "tag": "", + + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + ] + } +} +``` + +### Fields + +#### inet4_range + +IPv4 address range for FakeIP. + +#### inet6_address + +IPv6 address range for FakeIP. diff --git a/docs/configuration/dns/server/fakeip.zh.md b/docs/configuration/dns/server/fakeip.zh.md new file mode 100644 index 00000000..06dbdff0 --- /dev/null +++ b/docs/configuration/dns/server/fakeip.zh.md @@ -0,0 +1,35 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# Fake IP + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "fakeip", + "tag": "", + + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + ] + } +} +``` + +### 字段 + +#### inet4_range + +FakeIP 的 IPv4 地址范围。 + +#### inet6_range + +FakeIP 的 IPv6 地址范围。 \ No newline at end of file diff --git a/docs/configuration/dns/server/hosts.md b/docs/configuration/dns/server/hosts.md new file mode 100644 index 00000000..da76f619 --- /dev/null +++ b/docs/configuration/dns/server/hosts.md @@ -0,0 +1,127 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# Hosts + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "hosts", + "tag": "", + + "path": [], + "predefined": {} + } + ] + } +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### path + +List of paths to hosts files. + +`/etc/hosts` is used by default. + +`C:\Windows\System32\Drivers\etc\hosts` is used by default on Windows. + +Example: + +```json +{ + // "path": "/etc/hosts" + + "path": [ + "/etc/hosts", + "$HOME/.hosts" + ] +} +``` + +#### predefined + +Predefined hosts. + +Example: + +```json +{ + "predefined": { + "www.google.com": "127.0.0.1", + "localhost": [ + "127.0.0.1", + "::1" + ] + } +} +``` + +### Examples + +=== "Use hosts if available" + + === ":material-card-multiple: sing-box 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + ... + }, + { + "type": "hosts", + "tag": "hosts" + } + ], + "rules": [ + { + "action": "evaluate", + "server": "hosts" + }, + { + "match_response": true, + "ip_accept_any": true, + "action": "respond" + } + ] + } + } + ``` + + === ":material-card-remove: sing-box < 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + ... + }, + { + "type": "hosts", + "tag": "hosts" + } + ], + "rules": [ + { + "ip_accept_any": true, + "server": "hosts" + } + ] + } + } + ``` \ No newline at end of file diff --git a/docs/configuration/dns/server/hosts.zh.md b/docs/configuration/dns/server/hosts.zh.md new file mode 100644 index 00000000..3384adc7 --- /dev/null +++ b/docs/configuration/dns/server/hosts.zh.md @@ -0,0 +1,127 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# Hosts + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "hosts", + "tag": "", + + "path": [], + "predefined": {} + } + ] + } +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 字段 + +#### path + +hosts 文件路径列表。 + +默认使用 `/etc/hosts`。 + +在 Windows 上默认使用 `C:\Windows\System32\Drivers\etc\hosts`。 + +示例: + +```json +{ + // "path": "/etc/hosts" + + "path": [ + "/etc/hosts", + "$HOME/.hosts" + ] +} +``` + +#### predefined + +预定义的 hosts。 + +示例: + +```json +{ + "predefined": { + "www.google.com": "127.0.0.1", + "localhost": [ + "127.0.0.1", + "::1" + ] + } +} +``` + +### 示例 + +=== "如果可用则使用 hosts" + + === ":material-card-multiple: sing-box 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + ... + }, + { + "type": "hosts", + "tag": "hosts" + } + ], + "rules": [ + { + "action": "evaluate", + "server": "hosts" + }, + { + "match_response": true, + "ip_accept_any": true, + "action": "respond" + } + ] + } + } + ``` + + === ":material-card-remove: sing-box < 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + ... + }, + { + "type": "hosts", + "tag": "hosts" + } + ], + "rules": [ + { + "ip_accept_any": true, + "server": "hosts" + } + ] + } + } + ``` \ No newline at end of file diff --git a/docs/configuration/dns/server/http3.md b/docs/configuration/dns/server/http3.md new file mode 100644 index 00000000..dd81ba2d --- /dev/null +++ b/docs/configuration/dns/server/http3.md @@ -0,0 +1,71 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# DNS over HTTP3 (DoH3) + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "h3", + "tag": "", + + "server": "", + "server_port": 443, + + "path": "", + "headers": {}, + + "tls": {}, + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy H3 server" + + * The old server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + * The old server uses `address_resolver` and `address_strategy` to resolve the domain name in the server; the new one uses `domain_resolver` and `domain_strategy` in [Dial Fields](/configuration/shared/dial/) instead. + +### Fields + +#### server + +==Required== + +The address of the DNS server. + +If domain name is used, `domain_resolver` must also be set to resolve IP address. + +#### server_port + +The port of the DNS server. + +`443` will be used by default. + +#### path + +The path of the DNS server. + +`/dns-query` will be used by default. + +#### headers + +Additional headers to be sent to the DNS server. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/http3.zh.md b/docs/configuration/dns/server/http3.zh.md new file mode 100644 index 00000000..1032fedb --- /dev/null +++ b/docs/configuration/dns/server/http3.zh.md @@ -0,0 +1,71 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# DNS over HTTP3 (DoH3) + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "h3", + "tag": "", + + "server": "", + "server_port": 443, + + "path": "", + "headers": {}, + + "tls": {}, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版 H3 服务器的区别" + + * 旧服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + * 旧服务器使用 `address_resolver` 和 `address_strategy` 来解析服务器中的域名;新服务器改用 [拨号字段](/zh/configuration/shared/dial/) 中的 `domain_resolver` 和 `domain_strategy`。 + +### 字段 + +#### server + +==必填== + +DNS 服务器的地址。 + +如果使用域名,还必须设置 `domain_resolver` 来解析 IP 地址。 + +#### server_port + +DNS 服务器的端口。 + +默认使用 `443`。 + +#### path + +DNS 服务器的路径。 + +默认使用 `/dns-query`。 + +#### headers + +发送到 DNS 服务器的额外标头。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/https.md b/docs/configuration/dns/server/https.md new file mode 100644 index 00000000..46e69a55 --- /dev/null +++ b/docs/configuration/dns/server/https.md @@ -0,0 +1,71 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# DNS over HTTPS (DoH) + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "https", + "tag": "", + + "server": "", + "server_port": 443, + + "path": "", + "headers": {}, + + "tls": {}, + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy HTTPS server" + + * The old server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + * The old server uses `address_resolver` and `address_strategy` to resolve the domain name in the server; the new one uses `domain_resolver` and `domain_strategy` in [Dial Fields](/configuration/shared/dial/) instead. + +### Fields + +#### server + +==Required== + +The address of the DNS server. + +If domain name is used, `domain_resolver` must also be set to resolve IP address. + +#### server_port + +The port of the DNS server. + +`443` will be used by default. + +#### path + +The path of the DNS server. + +`/dns-query` will be used by default. + +#### headers + +Additional headers to be sent to the DNS server. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/https.zh.md b/docs/configuration/dns/server/https.zh.md new file mode 100644 index 00000000..7aa73c3f --- /dev/null +++ b/docs/configuration/dns/server/https.zh.md @@ -0,0 +1,71 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# DNS over HTTPS (DoH) + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "https", + "tag": "", + + "server": "", + "server_port": 443, + + "path": "", + "headers": {}, + + "tls": {}, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版 HTTPS 服务器的区别" + + * 旧服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + * 旧服务器使用 `address_resolver` 和 `address_strategy` 来解析服务器中的域名;新服务器改用 [拨号字段](/zh/configuration/shared/dial/) 中的 `domain_resolver` 和 `domain_strategy`。 + +### 字段 + +#### server + +==必填== + +DNS 服务器的地址。 + +如果使用域名,还必须设置 `domain_resolver` 来解析 IP 地址。 + +#### server_port + +DNS 服务器的端口。 + +默认使用 `443`。 + +#### path + +DNS 服务器的路径。 + +默认使用 `/dns-query`。 + +#### headers + +发送到 DNS 服务器的额外标头。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md new file mode 100644 index 00000000..b610cf5b --- /dev/null +++ b/docs/configuration/dns/server/index.md @@ -0,0 +1,48 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [type](#type) + +# DNS Server + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "", + "tag": "" + } + ] + } +} +``` + +#### type + +The type of the DNS server. + +| Type | Format | +|-----------------|---------------------------| +| empty (default) | :material-note-remove: [Legacy](./legacy/) | +| `local` | [Local](./local/) | +| `hosts` | [Hosts](./hosts/) | +| `tcp` | [TCP](./tcp/) | +| `udp` | [UDP](./udp/) | +| `tls` | [TLS](./tls/) | +| `quic` | [QUIC](./quic/) | +| `https` | [HTTPS](./https/) | +| `h3` | [HTTP/3](./http3/) | +| `dhcp` | [DHCP](./dhcp/) | +| `fakeip` | [Fake IP](./fakeip/) | +| `tailscale` | [Tailscale](./tailscale/) | +| `resolved` | [Resolved](./resolved/) | + +#### tag + +The tag of the DNS server. diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md new file mode 100644 index 00000000..d1a4dc3c --- /dev/null +++ b/docs/configuration/dns/server/index.zh.md @@ -0,0 +1,48 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [type](#type) + +# DNS Server + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "", + "tag": "" + } + ] + } +} +``` + +#### type + +DNS 服务器的类型。 + +| 类型 | 格式 | +|-----------------|---------------------------| +| empty (default) | :material-note-remove: [Legacy](./legacy/) | +| `local` | [Local](./local/) | +| `hosts` | [Hosts](./hosts/) | +| `tcp` | [TCP](./tcp/) | +| `udp` | [UDP](./udp/) | +| `tls` | [TLS](./tls/) | +| `quic` | [QUIC](./quic/) | +| `https` | [HTTPS](./https/) | +| `h3` | [HTTP/3](./http3/) | +| `dhcp` | [DHCP](./dhcp/) | +| `fakeip` | [Fake IP](./fakeip/) | +| `tailscale` | [Tailscale](./tailscale/) | +| `resolved` | [Resolved](./resolved/) | + +#### tag + +DNS 服务器的标签。 diff --git a/docs/configuration/dns/server/legacy.md b/docs/configuration/dns/server/legacy.md new file mode 100644 index 00000000..e27b19cb --- /dev/null +++ b/docs/configuration/dns/server/legacy.md @@ -0,0 +1,113 @@ +--- +icon: material/note-remove +--- + +!!! failure "Removed in sing-box 1.14.0" + + Legacy DNS servers are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-server-formats). + +!!! quote "Changes in sing-box 1.9.0" + + :material-plus: [client_subnet](#client_subnet) + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "tag": "", + "address": "", + "address_resolver": "", + "address_strategy": "", + "strategy": "", + "detour": "", + "client_subnet": "" + } + ] + } +} +``` + +### Fields + +#### tag + +The tag of the dns server. + +#### address + +==Required== + +The address of the dns server. + +| Protocol | Format | +|--------------------------------------|-------------------------------| +| `System` | `local` | +| `TCP` | `tcp://1.0.0.1` | +| `UDP` | `8.8.8.8` `udp://8.8.4.4` | +| `TLS` | `tls://dns.google` | +| `HTTPS` | `https://1.1.1.1/dns-query` | +| `QUIC` | `quic://dns.adguard.com` | +| `HTTP3` | `h3://8.8.8.8/dns-query` | +| `RCode` | `rcode://refused` | +| `DHCP` | `dhcp://auto` or `dhcp://en0` | +| [FakeIP](/configuration/dns/fakeip/) | `fakeip` | + +!!! warning "" + + To ensure that Android system DNS is in effect, rather than Go's built-in default resolver, enable CGO at compile time. + +!!! info "" + + the RCode transport is often used to block queries. Use with rules and the `disable_cache` rule option. + +| RCode | Description | +|-------------------|-----------------------| +| `success` | `No error` | +| `format_error` | `Format error` | +| `server_failure` | `Server failure` | +| `name_error` | `Non-existent domain` | +| `not_implemented` | `Not implemented` | +| `refused` | `Query refused` | + +#### address_resolver + +==Required if address contains domain== + +Tag of a another server to resolve the domain name in the address. + +#### address_strategy + +The domain strategy for resolving the domain name in the address. + +One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. + +`dns.strategy` will be used if empty. + +#### strategy + +Default domain strategy for resolving the domain names. + +One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. + +Take no effect if overridden by other settings. + +#### detour + +Tag of an outbound for connecting to the dns server. + +Default outbound will be used if empty. + +#### client_subnet + +!!! question "Since sing-box 1.9.0" + +Append a `edns0-subnet` OPT extra record with the specified IP prefix to every query by default. + +If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. + +Can be overridden by `rules.[].client_subnet`. + +Will override `dns.client_subnet`. diff --git a/docs/configuration/dns/server/legacy.zh.md b/docs/configuration/dns/server/legacy.zh.md new file mode 100644 index 00000000..2ad36839 --- /dev/null +++ b/docs/configuration/dns/server/legacy.zh.md @@ -0,0 +1,113 @@ +--- +icon: material/note-remove +--- + +!!! failure "已在 sing-box 1.14.0 移除" + + 旧的 DNS 服务器配置已在 sing-box 1.12.0 废弃且已在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 + +!!! quote "sing-box 1.9.0 中的更改" + + :material-plus: [client_subnet](#client_subnet) + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "tag": "", + "address": "", + "address_resolver": "", + "address_strategy": "", + "strategy": "", + "detour": "", + "client_subnet": "" + } + ] + } +} +``` + +### 字段 + +#### tag + +DNS 服务器的标签。 + +#### address + +==必填== + +DNS 服务器的地址。 + +| 协议 | 格式 | +|--------------------------------------|------------------------------| +| `System` | `local` | +| `TCP` | `tcp://1.0.0.1` | +| `UDP` | `8.8.8.8` `udp://8.8.4.4` | +| `TLS` | `tls://dns.google` | +| `HTTPS` | `https://1.1.1.1/dns-query` | +| `QUIC` | `quic://dns.adguard.com` | +| `HTTP3` | `h3://8.8.8.8/dns-query` | +| `RCode` | `rcode://refused` | +| `DHCP` | `dhcp://auto` 或 `dhcp://en0` | +| [FakeIP](/zh/configuration/dns/fakeip/) | `fakeip` | + +!!! warning "" + + 为了确保 Android 系统 DNS 生效,而不是 Go 的内置默认解析器,请在编译时启用 CGO。 + +!!! info "" + + RCode 传输层传输层常用于屏蔽请求. 与 DNS 规则和 `disable_cache` 规则选项一起使用。 + +| RCode | 描述 | +|-------------------|----------| +| `success` | `无错误` | +| `format_error` | `请求格式错误` | +| `server_failure` | `服务器出错` | +| `name_error` | `域名不存在` | +| `not_implemented` | `功能未实现` | +| `refused` | `请求被拒绝` | + +#### address_resolver + +==如果服务器地址包括域名则必须== + +用于解析本 DNS 服务器的域名的另一个 DNS 服务器的标签。 + +#### address_strategy + +用于解析本 DNS 服务器的域名的策略。 + +可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 + +默认使用 `dns.strategy`。 + +#### strategy + +默认解析策略。 + +可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 + +如果被其他设置覆盖则不生效。 + +#### detour + +用于连接到 DNS 服务器的出站的标签。 + +如果为空,将使用默认出站。 + +#### client_subnet + +!!! question "自 sing-box 1.9.0 起" + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +可以被 `rules.[].client_subnet` 覆盖。 + +将覆盖 `dns.client_subnet`。 diff --git a/docs/configuration/dns/server/local.md b/docs/configuration/dns/server/local.md new file mode 100644 index 00000000..aa7f095a --- /dev/null +++ b/docs/configuration/dns/server/local.md @@ -0,0 +1,61 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [prefer_go](#prefer_go) + +!!! question "Since sing-box 1.12.0" + +# Local + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "", + "prefer_go": false + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy local server" + + * The old legacy local server only handles IP requests; the new one handles all types of requests and supports concurrent for IP requests. + * The old local server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + +### Fields + +#### prefer_go + +!!! question "Since sing-box 1.13.0" + +When enabled, `local` DNS server will resolve DNS by dialing itself whenever possible. + +Specifically, it disables following behaviors which was added as features in sing-box 1.13.0: + +1. On Apple platforms: Attempt to resolve A/AAAA requests using `getaddrinfo` in NetworkExtension. +2. On Linux: Resolve through `systemd-resolvd`'s DBus interface when available. + +As a sole exception, it cannot disable the following behavior: + +1. In the Android graphical client, +`local` will always resolve DNS through the platform interface, +as there is no other way to obtain upstream DNS servers; +On devices running Android versions lower than 10, this interface can only resolve A/AAAA requests. + +2. On macOS, `local` will try DHCP first in Network Extension, since DHCP respects DIal Fields, +it will not be disabled by `prefer_go`. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/local.zh.md b/docs/configuration/dns/server/local.zh.md new file mode 100644 index 00000000..50ac05ac --- /dev/null +++ b/docs/configuration/dns/server/local.zh.md @@ -0,0 +1,61 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [prefer_go](#prefer_go) + +!!! question "自 sing-box 1.12.0 起" + +# Local + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "", + "prefer_go": false, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版本地服务器的区别" + + * 旧的传统本地服务器只处理 IP 请求;新的服务器处理所有类型的请求,并支持 IP 请求的并发处理。 + * 旧的本地服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + +### 字段 + +#### prefer_go + +!!! question "自 sing-box 1.13.0 起" + +启用后,`local` DNS 服务器将尽可能通过拨号自身来解析 DNS。 + +具体来说,它禁用了在 sing-box 1.13.0 中作为功能添加的以下行为: + +1. 在 Apple 平台上:尝试在 NetworkExtension 中使用 `getaddrinfo` 解析 A/AAAA 请求。 +2. 在 Linux 上:当可用时通过 `systemd-resolvd` 的 DBus 接口进行解析。 + +作为唯一的例外,它无法禁用以下行为: + +1. 在 Android 图形客户端中, +`local` 将始终通过平台接口解析 DNS, +因为没有其他方法来获取上游 DNS 服务器; +在运行 Android 10 以下版本的设备上,此接口只能解析 A/AAAA 请求。 + +2. 在 macOS 上,`local` 会在 Network Extension 中首先尝试 DHCP,由于 DHCP 遵循拨号字段, +它不会被 `prefer_go` 禁用。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/quic.md b/docs/configuration/dns/server/quic.md new file mode 100644 index 00000000..096264fe --- /dev/null +++ b/docs/configuration/dns/server/quic.md @@ -0,0 +1,58 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# DNS over QUIC (DoQ) + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "quic", + "tag": "", + + "server": "", + "server_port": 853, + + "tls": {}, + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy QUIC server" + + * The old server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + * The old server uses `address_resolver` and `address_strategy` to resolve the domain name in the server; the new one uses `domain_resolver` and `domain_strategy` in [Dial Fields](/configuration/shared/dial/) instead. + +### Fields + +#### server + +==Required== + +The address of the DNS server. + +If domain name is used, `domain_resolver` must also be set to resolve IP address. + +#### server_port + +The port of the DNS server. + +`853` will be used by default. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/quic.zh.md b/docs/configuration/dns/server/quic.zh.md new file mode 100644 index 00000000..c18c18ed --- /dev/null +++ b/docs/configuration/dns/server/quic.zh.md @@ -0,0 +1,58 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# DNS over QUIC (DoQ) + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "quic", + "tag": "", + + "server": "", + "server_port": 853, + + "tls": {}, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版 QUIC 服务器的区别" + + * 旧服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + * 旧服务器使用 `address_resolver` 和 `address_strategy` 来解析服务器中的域名;新服务器改用 [拨号字段](/zh/configuration/shared/dial/) 中的 `domain_resolver` 和 `domain_strategy`。 + +### 字段 + +#### server + +==必填== + +DNS 服务器的地址。 + +如果使用域名,还必须设置 `domain_resolver` 来解析 IP 地址。 + +#### server_port + +DNS 服务器的端口。 + +默认使用 `853`。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/resolved.md b/docs/configuration/dns/server/resolved.md new file mode 100644 index 00000000..75835c6b --- /dev/null +++ b/docs/configuration/dns/server/resolved.md @@ -0,0 +1,117 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# Resolved + +```json +{ + "dns": { + "servers": [ + { + "type": "resolved", + "tag": "", + + "service": "resolved", + "accept_default_resolvers": false + } + ] + } +} +``` + + +### Fields + +#### service + +==Required== + +The tag of the [Resolved Service](/configuration/service/resolved). + +#### accept_default_resolvers + +Indicates whether the default DNS resolvers should be accepted for fallback queries in addition to matching domains. + +Specifically, default DNS resolvers are DNS servers that have `SetLinkDefaultRoute` or `SetLinkDomains ~.` set. + +If not enabled, `NXDOMAIN` will be returned for requests that do not match search or match domains. + +### Examples + +=== "Split DNS only" + + === ":material-card-multiple: sing-box 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "resolved", + "tag": "resolved", + "service": "resolved" + } + ], + "rules": [ + { + "action": "evaluate", + "server": "resolved" + }, + { + "match_response": true, + "ip_accept_any": true, + "action": "respond" + } + ] + } + } + ``` + + === ":material-card-remove: sing-box < 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "resolved", + "tag": "resolved", + "service": "resolved" + } + ], + "rules": [ + { + "ip_accept_any": true, + "server": "resolved" + } + ] + } + } + ``` + +=== "Use as global DNS" + + ```json + { + "dns": { + "servers": [ + { + "type": "resolved", + "service": "resolved", + "accept_default_resolvers": true + } + ] + } + } + ``` \ No newline at end of file diff --git a/docs/configuration/dns/server/resolved.zh.md b/docs/configuration/dns/server/resolved.zh.md new file mode 100644 index 00000000..8747e831 --- /dev/null +++ b/docs/configuration/dns/server/resolved.zh.md @@ -0,0 +1,116 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# Resolved + +```json +{ + "dns": { + "servers": [ + { + "type": "resolved", + "tag": "", + + "service": "resolved", + "accept_default_resolvers": false + } + ] + } +} +``` + +### 字段 + +#### service + +==必填== + +[Resolved 服务](/zh/configuration/service/resolved) 的标签。 + +#### accept_default_resolvers + +指示是否除了匹配域名外,还应接受默认 DNS 解析器以进行回退查询。 + +具体来说,默认 DNS 解析器是设置了 `SetLinkDefaultRoute` 或 `SetLinkDomains ~.` 的 DNS 服务器。 + +如果未启用,对于不匹配搜索域或匹配域的请求,将返回 `NXDOMAIN`。 + +### 示例 + +=== "仅分割 DNS" + + === ":material-card-multiple: sing-box 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "resolved", + "tag": "resolved", + "service": "resolved" + } + ], + "rules": [ + { + "action": "evaluate", + "server": "resolved" + }, + { + "match_response": true, + "ip_accept_any": true, + "action": "respond" + } + ] + } + } + ``` + + === ":material-card-remove: sing-box < 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "resolved", + "tag": "resolved", + "service": "resolved" + } + ], + "rules": [ + { + "ip_accept_any": true, + "server": "resolved" + } + ] + } + } + ``` + +=== "用作全局 DNS" + + ```json + { + "dns": { + "servers": [ + { + "type": "resolved", + "service": "resolved", + "accept_default_resolvers": true + } + ] + } + } + ``` \ No newline at end of file diff --git a/docs/configuration/dns/server/tailscale.md b/docs/configuration/dns/server/tailscale.md new file mode 100644 index 00000000..2677f2b8 --- /dev/null +++ b/docs/configuration/dns/server/tailscale.md @@ -0,0 +1,116 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# Tailscale + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "tailscale", + "tag": "", + + "endpoint": "ts-ep", + "accept_default_resolvers": false + } + ] + } +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [Tailscale Endpoint](/configuration/endpoint/tailscale). + +#### accept_default_resolvers + +Indicates whether default DNS resolvers should be accepted for fallback queries in addition to MagicDNS。 + +if not enabled, `NXDOMAIN` will be returned for non-Tailscale domain queries. + +### Examples + +=== "MagicDNS only" + + === ":material-card-multiple: sing-box 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "tailscale", + "tag": "ts", + "endpoint": "ts-ep" + } + ], + "rules": [ + { + "action": "evaluate", + "server": "ts" + }, + { + "match_response": true, + "ip_accept_any": true, + "action": "respond" + } + ] + } + } + ``` + + === ":material-card-remove: sing-box < 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "tailscale", + "tag": "ts", + "endpoint": "ts-ep" + } + ], + "rules": [ + { + "ip_accept_any": true, + "server": "ts" + } + ] + } + } + ``` + +=== "Use as global DNS" + + ```json + { + "dns": { + "servers": [ + { + "type": "tailscale", + "endpoint": "ts-ep", + "accept_default_resolvers": true + } + ] + } + } + ``` diff --git a/docs/configuration/dns/server/tailscale.zh.md b/docs/configuration/dns/server/tailscale.zh.md new file mode 100644 index 00000000..10d84038 --- /dev/null +++ b/docs/configuration/dns/server/tailscale.zh.md @@ -0,0 +1,116 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# Tailscale + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "tailscale", + "tag": "", + + "endpoint": "ts-ep", + "accept_default_resolvers": false + } + ] + } +} +``` + +### 字段 + +#### endpoint + +==必填== + +[Tailscale 端点](/zh/configuration/endpoint/tailscale) 的标签。 + +#### accept_default_resolvers + +指示是否除了 MagicDNS 外,还应接受默认 DNS 解析器以进行回退查询。 + +如果未启用,对于非 Tailscale 域名查询将返回 `NXDOMAIN`。 + +### 示例 + +=== "仅 MagicDNS" + + === ":material-card-multiple: sing-box 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "tailscale", + "tag": "ts", + "endpoint": "ts-ep" + } + ], + "rules": [ + { + "action": "evaluate", + "server": "ts" + }, + { + "match_response": true, + "ip_accept_any": true, + "action": "respond" + } + ] + } + } + ``` + + === ":material-card-remove: sing-box < 1.14.0" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "tailscale", + "tag": "ts", + "endpoint": "ts-ep" + } + ], + "rules": [ + { + "ip_accept_any": true, + "server": "ts" + } + ] + } + } + ``` + +=== "用作全局 DNS" + + ```json + { + "dns": { + "servers": [ + { + "type": "tailscale", + "endpoint": "ts-ep", + "accept_default_resolvers": true + } + ] + } + } + ``` \ No newline at end of file diff --git a/docs/configuration/dns/server/tcp.md b/docs/configuration/dns/server/tcp.md new file mode 100644 index 00000000..c0a4ceaf --- /dev/null +++ b/docs/configuration/dns/server/tcp.md @@ -0,0 +1,52 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# TCP + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "tcp", + "tag": "", + + "server": "", + "server_port": 53, + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy TCP server" + + * The old server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + * The old server uses `address_resolver` and `address_strategy` to resolve the domain name in the server; the new one uses `domain_resolver` and `domain_strategy` in [Dial Fields](/configuration/shared/dial/) instead. + +### Fields + +#### server + +==Required== + +The address of the DNS server. + +If domain name is used, `domain_resolver` must also be set to resolve IP address. + +#### server_port + +The port of the DNS server. + +`53` will be used by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/tcp.zh.md b/docs/configuration/dns/server/tcp.zh.md new file mode 100644 index 00000000..6f439bdf --- /dev/null +++ b/docs/configuration/dns/server/tcp.zh.md @@ -0,0 +1,52 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# TCP + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "tcp", + "tag": "", + + "server": "", + "server_port": 53, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版 TCP 服务器的区别" + + * 旧服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + * 旧服务器使用 `address_resolver` 和 `address_strategy` 来解析服务器中的域名;新服务器改用 [拨号字段](/zh/configuration/shared/dial/) 中的 `domain_resolver` 和 `domain_strategy`。 + +### 字段 + +#### server + +==必填== + +DNS 服务器的地址。 + +如果使用域名,还必须设置 `domain_resolver` 来解析 IP 地址。 + +#### server_port + +DNS 服务器的端口。 + +默认使用 `53`。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/tls.md b/docs/configuration/dns/server/tls.md new file mode 100644 index 00000000..0e3be226 --- /dev/null +++ b/docs/configuration/dns/server/tls.md @@ -0,0 +1,58 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# DNS over TLS (DoT) + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "tls", + "tag": "", + + "server": "", + "server_port": 853, + + "tls": {}, + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy TLS server" + + * The old server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + * The old server uses `address_resolver` and `address_strategy` to resolve the domain name in the server; the new one uses `domain_resolver` and `domain_strategy` in [Dial Fields](/configuration/shared/dial/) instead. + +### Fields + +#### server + +==Required== + +The address of the DNS server. + +If domain name is used, `domain_resolver` must also be set to resolve IP address. + +#### server_port + +The port of the DNS server. + +`853` will be used by default. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/tls.zh.md b/docs/configuration/dns/server/tls.zh.md new file mode 100644 index 00000000..afd1111a --- /dev/null +++ b/docs/configuration/dns/server/tls.zh.md @@ -0,0 +1,58 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# DNS over TLS (DoT) + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "tls", + "tag": "", + + "server": "", + "server_port": 853, + + "tls": {}, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版 TLS 服务器的区别" + + * 旧服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + * 旧服务器使用 `address_resolver` 和 `address_strategy` 来解析服务器中的域名;新服务器改用 [拨号字段](/zh/configuration/shared/dial/) 中的 `domain_resolver` 和 `domain_strategy`。 + +### 字段 + +#### server + +==必填== + +DNS 服务器的地址。 + +如果使用域名,还必须设置 `domain_resolver` 来解析 IP 地址。 + +#### server_port + +DNS 服务器的端口。 + +默认使用 `853`。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/dns/server/udp.md b/docs/configuration/dns/server/udp.md new file mode 100644 index 00000000..c551d3c9 --- /dev/null +++ b/docs/configuration/dns/server/udp.md @@ -0,0 +1,52 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# UDP + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "udp", + "tag": "", + + "server": "", + "server_port": 53, + + // Dial Fields + } + ] + } +} +``` + +!!! info "Difference from legacy UDP server" + + * The old server uses default outbound by default unless detour is specified; the new one uses dialer just like outbound, which is equivalent to using an empty direct outbound by default. + * The old server uses `address_resolver` and `address_strategy` to resolve the domain name in the server; the new one uses `domain_resolver` and `domain_strategy` in [Dial Fields](/configuration/shared/dial/) instead. + +### Fields + +#### server + +==Required== + +The address of the DNS server. + +If domain name is used, `domain_resolver` must also be set to resolve IP address. + +#### server_port + +The port of the DNS server. + +`53` will be used by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/udp.zh.md b/docs/configuration/dns/server/udp.zh.md new file mode 100644 index 00000000..63feedd8 --- /dev/null +++ b/docs/configuration/dns/server/udp.zh.md @@ -0,0 +1,52 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# UDP + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "udp", + "tag": "", + + "server": "", + "server_port": 53, + + // 拨号字段 + } + ] + } +} +``` + +!!! info "与旧版 UDP 服务器的区别" + + * 旧服务器默认使用默认出站,除非指定了绕行;新服务器像出站一样使用拨号器,相当于默认使用空的直连出站。 + * 旧服务器使用 `address_resolver` 和 `address_strategy` 来解析服务器中的域名;新服务器改用 [拨号字段](/zh/configuration/shared/dial/) 中的 `domain_resolver` 和 `domain_strategy`。 + +### 字段 + +#### server + +==必填== + +DNS 服务器的地址。 + +如果使用域名,还必须设置 `domain_resolver` 来解析 IP 地址。 + +#### server_port + +DNS 服务器的端口。 + +默认使用 `53`。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/endpoint/index.md b/docs/configuration/endpoint/index.md new file mode 100644 index 00000000..b409a783 --- /dev/null +++ b/docs/configuration/endpoint/index.md @@ -0,0 +1,29 @@ +!!! question "Since sing-box 1.11.0" + +# Endpoint + +An endpoint is a protocol with inbound and outbound behavior. + +### Structure + +```json +{ + "endpoints": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### Fields + +| Type | Format | +|-------------|---------------------------| +| `wireguard` | [WireGuard](./wireguard/) | +| `tailscale` | [Tailscale](./tailscale/) | + +#### tag + +The tag of the endpoint. diff --git a/docs/configuration/endpoint/index.zh.md b/docs/configuration/endpoint/index.zh.md new file mode 100644 index 00000000..f7e71b75 --- /dev/null +++ b/docs/configuration/endpoint/index.zh.md @@ -0,0 +1,29 @@ +!!! question "自 sing-box 1.11.0 起" + +# 端点 + +端点是具有入站和出站行为的协议。 + +### 结构 + +```json +{ + "endpoints": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### 字段 + +| 类型 | 格式 | +|-------------|---------------------------| +| `wireguard` | [WireGuard](./wireguard/) | +| `tailscale` | [Tailscale](./tailscale/) | + +#### tag + +端点的标签。 diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md new file mode 100644 index 00000000..6cf10e2b --- /dev/null +++ b/docs/configuration/endpoint/tailscale.md @@ -0,0 +1,157 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [relay_server_port](#relay_server_port) + :material-plus: [relay_server_static_endpoints](#relay_server_static_endpoints) + :material-plus: [system_interface](#system_interface) + :material-plus: [system_interface_name](#system_interface_name) + :material-plus: [system_interface_mtu](#system_interface_mtu) + :material-plus: [advertise_tags](#advertise_tags) + +!!! question "Since sing-box 1.12.0" + +### Structure + +```json +{ + "type": "tailscale", + "tag": "ts-ep", + "state_directory": "", + "auth_key": "", + "control_url": "", + "ephemeral": false, + "hostname": "", + "accept_routes": false, + "exit_node": "", + "exit_node_allow_lan_access": false, + "advertise_routes": [], + "advertise_exit_node": false, + "advertise_tags": [], + "relay_server_port": 0, + "relay_server_static_endpoints": [], + "system_interface": false, + "system_interface_name": "", + "system_interface_mtu": 0, + "udp_timeout": "5m", + + ... // Dial Fields +} +``` + +### Fields + +#### state_directory + +The directory where the Tailscale state is stored. + +`tailscale` is used by default. + +Example: `$HOME/.tailscale` + +#### auth_key + +!!! note + + Auth key is not required. By default, sing-box will log the login URL (or popup a notification on graphical clients). + +The auth key to create the node. If the node is already created (from state previously stored), then this field is not +used. + +#### control_url + +The coordination server URL. + +`https://controlplane.tailscale.com` is used by default. + +#### ephemeral + +Indicates whether the instance should register as an Ephemeral node (https://tailscale.com/s/ephemeral-nodes). + +#### hostname + +The hostname of the node. + +System hostname is used by default. + +Example: `localhost` + +#### accept_routes + +Indicates whether the node should accept routes advertised by other nodes. + +#### exit_node + +The exit node name or IP address to use. + +#### exit_node_allow_lan_access + +!!! note + + When the exit node does not have a corresponding advertised route, private traffics cannot be routed to the exit node even if `exit_node_allow_lan_access is` set. + +Indicates whether locally accessible subnets should be routed directly or via the exit node. + +#### advertise_routes + +CIDR prefixes to advertise into the Tailscale network as reachable through the current node. + +Example: `["192.168.1.1/24"]` + +#### advertise_exit_node + +Indicates whether the node should advertise itself as an exit node. + +#### advertise_tags + +!!! question "Since sing-box 1.13.0" + +Tags to advertise for this node, for ACL enforcement purposes. + +Example: `["tag:server"]` + +#### relay_server_port + +!!! question "Since sing-box 1.13.0" + +The port to listen on for incoming relay connections from other Tailscale nodes. + +#### relay_server_static_endpoints + +!!! question "Since sing-box 1.13.0" + +Static endpoints to advertise for the relay server. + +#### system_interface + +!!! question "Since sing-box 1.13.0" + +Create a system TUN interface for Tailscale. + +#### system_interface_name + +!!! question "Since sing-box 1.13.0" + +Custom TUN interface name. By default, `tailscale` (or `utun` on macOS) will be used. + +#### system_interface_mtu + +!!! question "Since sing-box 1.13.0" + +Override the TUN MTU. By default, Tailscale's own MTU is used. + +#### udp_timeout + +UDP NAT expiration time. + +`5m` will be used by default. + +### Dial Fields + +!!! note + + Dial Fields in Tailscale endpoints only control how it connects to the control plane and have nothing to do with actual connections. + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md new file mode 100644 index 00000000..f881dd67 --- /dev/null +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -0,0 +1,156 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [relay_server_port](#relay_server_port) + :material-plus: [relay_server_static_endpoints](#relay_server_static_endpoints) + :material-plus: [system_interface](#system_interface) + :material-plus: [system_interface_name](#system_interface_name) + :material-plus: [system_interface_mtu](#system_interface_mtu) + :material-plus: [advertise_tags](#advertise_tags) + +!!! question "自 sing-box 1.12.0 起" + +### 结构 + +```json +{ + "type": "tailscale", + "tag": "ts-ep", + "state_directory": "", + "auth_key": "", + "control_url": "", + "ephemeral": false, + "hostname": "", + "accept_routes": false, + "exit_node": "", + "exit_node_allow_lan_access": false, + "advertise_routes": [], + "advertise_exit_node": false, + "advertise_tags": [], + "relay_server_port": 0, + "relay_server_static_endpoints": [], + "system_interface": false, + "system_interface_name": "", + "system_interface_mtu": 0, + "udp_timeout": "5m", + + ... // 拨号字段 +} +``` + +### 字段 + +#### state_directory + +存储 Tailscale 状态的目录。 + +默认使用 `tailscale`。 + +示例:`$HOME/.tailscale` + +#### auth_key + +!!! note + + 认证密钥不是必需的。默认情况下,sing-box 将记录登录 URL(或在图形客户端上弹出通知)。 + +用于创建节点的认证密钥。如果节点已经创建(从之前存储的状态),则不使用此字段。 + +#### control_url + +协调服务器 URL。 + +默认使用 `https://controlplane.tailscale.com`。 + +#### ephemeral + +指示实例是否应注册为临时节点 (https://tailscale.com/s/ephemeral-nodes)。 + +#### hostname + +节点的主机名。 + +默认使用系统主机名。 + +示例:`localhost` + +#### accept_routes + +指示节点是否应接受其他节点通告的路由。 + +#### exit_node + +要使用的出口节点名称或 IP 地址。 + +#### exit_node_allow_lan_access + +!!! note + + 当出口节点没有相应的通告路由时,即使设置了 `exit_node_allow_lan_access`,私有流量也无法路由到出口节点。 + +指示本地可访问的子网应该直接路由还是通过出口节点路由。 + +#### advertise_routes + +通告到 Tailscale 网络的 CIDR 前缀,作为可通过当前节点访问的路由。 + +示例:`["192.168.1.1/24"]` + +#### advertise_exit_node + +指示节点是否应将自己通告为出口节点。 + +#### advertise_tags + +!!! question "自 sing-box 1.13.0 起" + +为此节点通告的标签,用于 ACL 执行。 + +示例:`["tag:server"]` + +#### relay_server_port + +!!! question "自 sing-box 1.13.0 起" + +监听来自其他 Tailscale 节点的中继连接的端口。 + +#### relay_server_static_endpoints + +!!! question "自 sing-box 1.13.0 起" + +为中继服务器通告的静态端点。 + +#### system_interface + +!!! question "自 sing-box 1.13.0 起" + +为 Tailscale 创建系统 TUN 接口。 + +#### system_interface_name + +!!! question "自 sing-box 1.13.0 起" + +自定义 TUN 接口名。默认使用 `tailscale`(macOS 上为 `utun`)。 + +#### system_interface_mtu + +!!! question "自 sing-box 1.13.0 起" + +覆盖 TUN 的 MTU。默认使用 Tailscale 自己的 MTU。 + +#### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +### 拨号字段 + +!!! note + + Tailscale 端点中的拨号字段仅控制它如何连接到控制平面,与实际连接无关。 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 diff --git a/docs/configuration/endpoint/wireguard.md b/docs/configuration/endpoint/wireguard.md new file mode 100644 index 00000000..dc3b8228 --- /dev/null +++ b/docs/configuration/endpoint/wireguard.md @@ -0,0 +1,129 @@ +!!! question "Since sing-box 1.11.0" + +### Structure + +```json +{ + "type": "wireguard", + "tag": "wg-ep", + + "system": false, + "name": "", + "mtu": 1408, + "address": [], + "private_key": "", + "listen_port": 10000, + "peers": [ + { + "address": "127.0.0.1", + "port": 10001, + "public_key": "", + "pre_shared_key": "", + "allowed_ips": [], + "persistent_keepalive_interval": 0, + "reserved": [0, 0, 0] + } + ], + "udp_timeout": "", + "workers": 0, + + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### system + +Use system interface. + +Requires privilege and cannot conflict with exists system interfaces. + +#### name + +Custom interface name for system interface. + +#### mtu + +WireGuard MTU. + +`1408` will be used by default. + +#### address + +==Required== + +List of IP (v4 or v6) address prefixes to be assigned to the interface. + +#### private_key + +==Required== + +WireGuard requires base64-encoded public and private keys. These can be generated using the wg(8) utility: + +```shell +wg genkey +echo "private key" || wg pubkey +``` + +or `sing-box generate wg-keypair`. + +#### peers + +==Required== + +List of WireGuard peers. + +#### peers.address + +WireGuard peer address. + +#### peers.port + +WireGuard peer port. + +#### peers.public_key + +==Required== + +WireGuard peer public key. + +#### peers.pre_shared_key + +WireGuard peer pre-shared key. + +#### peers.allowed_ips + +==Required== + +WireGuard allowed IPs. + +#### peers.persistent_keepalive_interval + +WireGuard persistent keepalive interval, in seconds. + +Disabled by default. + +#### peers.reserved + +WireGuard reserved field bytes. + +#### udp_timeout + +UDP NAT expiration time. + +`5m` will be used by default. + +#### workers + +WireGuard worker count. + +CPU count is used by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/endpoint/wireguard.zh.md b/docs/configuration/endpoint/wireguard.zh.md new file mode 100644 index 00000000..1935135f --- /dev/null +++ b/docs/configuration/endpoint/wireguard.zh.md @@ -0,0 +1,131 @@ +!!! question "自 sing-box 1.11.0 起" + +### 结构 + +```json +{ + "type": "wireguard", + "tag": "wg-ep", + + "system": false, + "name": "", + "mtu": 1408, + "address": [], + "private_key": "", + "listen_port": 10000, + "peers": [ + { + "address": "127.0.0.1", + "port": 10001, + "public_key": "", + "pre_shared_key": "", + "allowed_ips": [], + "persistent_keepalive_interval": 0, + "reserved": [0, 0, 0] + } + ], + "udp_timeout": "", + "workers": 0, + + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 字段 + +#### system + +使用系统设备。 + +需要特权且不能与已有系统接口冲突。 + +#### name + +为系统接口自定义设备名称。 + +#### mtu + +WireGuard MTU。 + +默认使用 1408。 + +#### address + +==必填== + +接口的 IPv4/IPv6 地址或地址段的列表。 + +要分配给接口的 IP(v4 或 v6)地址段列表。 + +#### private_key + +==必填== + +WireGuard 需要 base64 编码的公钥和私钥。 这些可以使用 wg(8) 实用程序生成: + +```shell +wg genkey +echo "private key" || wg pubkey +``` + +或 `sing-box generate wg-keypair`. + +#### peers + +==必填== + +WireGuard 对等方的列表。 + +#### peers.address + +对等方的 IP 地址。 + +#### peers.port + +对等方的 WireGuard 端口。 + +#### peers.public_key + +==必填== + +对等方的 WireGuard 公钥。 + +#### peers.pre_shared_key + +对等方的预共享密钥。 + +#### peers.allowed_ips + +==必填== + +对等方的允许 IP 地址。 + +#### peers.persistent_keepalive_interval + +对等方的持久性保持活动间隔,以秒为单位。 + +默认禁用。 + +#### peers.reserved + +对等方的保留字段字节。 + +#### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +#### workers + +WireGuard worker 数量。 + +默认使用 CPU 数量。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/experimental/cache-file.md b/docs/configuration/experimental/cache-file.md new file mode 100644 index 00000000..b93aa190 --- /dev/null +++ b/docs/configuration/experimental/cache-file.md @@ -0,0 +1,70 @@ +!!! question "Since sing-box 1.8.0" + +!!! quote "Changes in sing-box 1.14.0" + + :material-delete-clock: [store_rdrc](#store_rdrc) + :material-plus: [store_dns](#store_dns) + +!!! quote "Changes in sing-box 1.9.0" + + :material-plus: [store_rdrc](#store_rdrc) + :material-plus: [rdrc_timeout](#rdrc_timeout) + +### Structure + +```json +{ + "enabled": true, + "path": "", + "cache_id": "", + "store_fakeip": false, + "store_rdrc": false, + "rdrc_timeout": "", + "store_dns": false +} +``` + +### Fields + +#### enabled + +Enable cache file. + +#### path + +Path to the cache file. + +`cache.db` will be used if empty. + +#### cache_id + +Identifier in the cache file + +If not empty, configuration specified data will use a separate store keyed by it. + +#### store_fakeip + +Store fakeip in the cache file + +#### store_rdrc + +!!! failure "Deprecated in sing-box 1.14.0" + + `store_rdrc` is deprecated and will be removed in sing-box 1.16.0, check [Migration](/migration/#migrate-store-rdrc). + +Store rejected DNS response cache in the cache file + +The check results of [Legacy Address Filter Fields](/configuration/dns/rule/#legacy-address-filter-fields) +will be cached until expiration. + +#### rdrc_timeout + +Timeout of rejected DNS response cache. + +`7d` is used by default. + +#### store_dns + +!!! question "Since sing-box 1.14.0" + +Store DNS cache in the cache file. diff --git a/docs/configuration/experimental/cache-file.zh.md b/docs/configuration/experimental/cache-file.zh.md new file mode 100644 index 00000000..5382f3a1 --- /dev/null +++ b/docs/configuration/experimental/cache-file.zh.md @@ -0,0 +1,67 @@ +!!! question "自 sing-box 1.8.0 起" + +!!! quote "sing-box 1.14.0 中的更改" + + :material-delete-clock: [store_rdrc](#store_rdrc) + :material-plus: [store_dns](#store_dns) + +!!! quote "sing-box 1.9.0 中的更改" + + :material-plus: [store_rdrc](#store_rdrc) + :material-plus: [rdrc_timeout](#rdrc_timeout) + +### 结构 + +```json +{ + "enabled": true, + "path": "", + "cache_id": "", + "store_fakeip": false, + "store_rdrc": false, + "rdrc_timeout": "", + "store_dns": false +} +``` + +### 字段 + +#### enabled + +启用缓存文件。 + +#### path + +缓存文件路径,默认使用`cache.db`。 + +#### cache_id + +缓存文件中的标识符。 + +如果不为空,配置特定的数据将使用由其键控的单独存储。 + +#### store_fakeip + +将 fakeip 存储在缓存文件中。 + +#### store_rdrc + +!!! failure "已在 sing-box 1.14.0 废弃" + + `store_rdrc` 已在 sing-box 1.14.0 废弃,且将在 sing-box 1.16.0 中被移除,参阅[迁移指南](/zh/migration/#迁移-store_rdrc)。 + +将拒绝的 DNS 响应缓存存储在缓存文件中。 + +[旧版地址筛选字段](/zh/configuration/dns/rule/#旧版地址筛选字段) 的检查结果将被缓存至过期。 + +#### rdrc_timeout + +拒绝的 DNS 响应缓存超时。 + +默认使用 `7d`。 + +#### store_dns + +!!! question "自 sing-box 1.14.0 起" + +将 DNS 缓存存储在缓存文件中。 diff --git a/docs/configuration/experimental/clash-api.md b/docs/configuration/experimental/clash-api.md new file mode 100644 index 00000000..a8908940 --- /dev/null +++ b/docs/configuration/experimental/clash-api.md @@ -0,0 +1,166 @@ +!!! quote "Changes in sing-box 1.10.0" + + :material-plus: [access_control_allow_origin](#access_control_allow_origin) + :material-plus: [access_control_allow_private_network](#access_control_allow_private_network) + +!!! quote "Changes in sing-box 1.8.0" + + :material-delete-alert: [store_mode](#store_mode) + :material-delete-alert: [store_selected](#store_selected) + :material-delete-alert: [store_fakeip](#store_fakeip) + :material-delete-alert: [cache_file](#cache_file) + :material-delete-alert: [cache_id](#cache_id) + +### Structure + +=== "Structure" + + ```json + { + "external_controller": "127.0.0.1:9090", + "external_ui": "", + "external_ui_download_url": "", + "external_ui_download_detour": "", + "secret": "", + "default_mode": "", + "access_control_allow_origin": [], + "access_control_allow_private_network": false, + + // Deprecated + + "store_mode": false, + "store_selected": false, + "store_fakeip": false, + "cache_file": "", + "cache_id": "" + } + ``` + +=== "Example (online)" + + !!! question "Since sing-box 1.10.0" + + ```json + { + "external_controller": "127.0.0.1:9090", + "access_control_allow_origin": [ + "http://127.0.0.1", + "http://yacd.haishan.me" + ], + "access_control_allow_private_network": true + } + ``` + +=== "Example (download)" + + !!! question "Since sing-box 1.10.0" + + ```json + { + "external_controller": "0.0.0.0:9090", + "external_ui": "dashboard" + // "external_ui_download_detour": "direct" + } + ``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### external_controller + +RESTful web API listening address. Clash API will be disabled if empty. + +#### external_ui + +A relative path to the configuration directory or an absolute path to a +directory in which you put some static web resource. sing-box will then +serve it at `http://{{external-controller}}/ui`. + +#### external_ui_download_url + +ZIP download URL for the external UI, will be used if the specified `external_ui` directory is empty. + +`https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip` will be used if empty. + +#### external_ui_download_detour + +The tag of the outbound to download the external UI. + +Default outbound will be used if empty. + +#### secret + +Secret for the RESTful API (optional) +Authenticate by spedifying HTTP header `Authorization: Bearer ${secret}` +ALWAYS set a secret if RESTful API is listening on 0.0.0.0 + +#### default_mode + +Default mode in clash, `Rule` will be used if empty. + +This setting has no direct effect, but can be used in routing and DNS rules via the `clash_mode` rule item. + +#### access_control_allow_origin + +!!! question "Since sing-box 1.10.0" + +CORS allowed origins, `*` will be used if empty. + +To access the Clash API on a private network from a public website, you must explicitly specify it in `access_control_allow_origin` instead of using `*`. + +#### access_control_allow_private_network + +!!! question "Since sing-box 1.10.0" + +Allow access from private network. + +To access the Clash API on a private network from a public website, `access_control_allow_private_network` must be enabled. + +#### store_mode + +!!! failure "Deprecated in sing-box 1.8.0" + + `store_mode` is deprecated in Clash API and enabled by default if `cache_file.enabled`. + +Store Clash mode in cache file. + +#### store_selected + +!!! failure "Deprecated in sing-box 1.8.0" + + `store_selected` is deprecated in Clash API and enabled by default if `cache_file.enabled`. + +!!! note "" + + The tag must be set for target outbounds. + +Store selected outbound for the `Selector` outbound in cache file. + +#### store_fakeip + +!!! failure "Deprecated in sing-box 1.8.0" + + `store_selected` is deprecated in Clash API and migrated to `cache_file.store_fakeip`. + +Store fakeip in cache file. + +#### cache_file + +!!! failure "Deprecated in sing-box 1.8.0" + + `cache_file` is deprecated in Clash API and migrated to `cache_file.enabled` and `cache_file.path`. + +Cache file path, `cache.db` will be used if empty. + +#### cache_id + +!!! failure "Deprecated in sing-box 1.8.0" + + `cache_id` is deprecated in Clash API and migrated to `cache_file.cache_id`. + +Identifier in cache file. + +If not empty, configuration specified data will use a separate store keyed by it. diff --git a/docs/configuration/experimental/clash-api.zh.md b/docs/configuration/experimental/clash-api.zh.md new file mode 100644 index 00000000..f86b2cac --- /dev/null +++ b/docs/configuration/experimental/clash-api.zh.md @@ -0,0 +1,164 @@ +!!! quote "sing-box 1.10.0 中的更改" + + :material-plus: [access_control_allow_origin](#access_control_allow_origin) + :material-plus: [access_control_allow_private_network](#access_control_allow_private_network) + +!!! quote "sing-box 1.8.0 中的更改" + + :material-delete-alert: [store_mode](#store_mode) + :material-delete-alert: [store_selected](#store_selected) + :material-delete-alert: [store_fakeip](#store_fakeip) + :material-delete-alert: [cache_file](#cache_file) + :material-delete-alert: [cache_id](#cache_id) + +### 结构 + +=== "结构" + + ```json + { + "external_controller": "127.0.0.1:9090", + "external_ui": "", + "external_ui_download_url": "", + "external_ui_download_detour": "", + "secret": "", + "default_mode": "", + "access_control_allow_origin": [], + "access_control_allow_private_network": false, + + // Deprecated + + "store_mode": false, + "store_selected": false, + "store_fakeip": false, + "cache_file": "", + "cache_id": "" + } + ``` + +=== "示例 (在线)" + + !!! question "自 sing-box 1.10.0 起" + + ```json + { + "external_controller": "127.0.0.1:9090", + "access_control_allow_origin": [ + "http://127.0.0.1", + "http://yacd.haishan.me" + ], + "access_control_allow_private_network": true + } + ``` + +=== "示例 (下载)" + + !!! question "自 sing-box 1.10.0 起" + + ```json + { + "external_controller": "0.0.0.0:9090", + "external_ui": "dashboard" + // "external_ui_download_detour": "direct" + } + ``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### Fields + +#### external_controller + +RESTful web API 监听地址。如果为空,则禁用 Clash API。 + +#### external_ui + +到静态网页资源目录的相对路径或绝对路径。sing-box 会在 `http://{{external-controller}}/ui` 下提供它。 + +#### external_ui_download_url + +静态网页资源的 ZIP 下载 URL,如果指定的 `external_ui` 目录为空,将使用。 + +默认使用 `https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip`。 + +#### external_ui_download_detour + +用于下载静态网页资源的出站的标签。 + +如果为空,将使用默认出站。 + +#### secret + +RESTful API 的密钥(可选) +通过指定 HTTP 标头 `Authorization: Bearer ${secret}` 进行身份验证 +如果 RESTful API 正在监听 0.0.0.0,请始终设置一个密钥。 + +#### default_mode + +Clash 中的默认模式,默认使用 `Rule`。 + +此设置没有直接影响,但可以通过 `clash_mode` 规则项在路由和 DNS 规则中使用。 + +#### access_control_allow_origin + +!!! question "自 sing-box 1.10.0 起" + +允许的 CORS 来源,默认使用 `*`。 + +要从公共网站访问私有网络上的 Clash API,必须在 `access_control_allow_origin` 中明确指定它而不是使用 `*`。 + +#### access_control_allow_private_network + +!!! question "自 sing-box 1.10.0 起" + +允许从私有网络访问。 + +要从公共网站访问私有网络上的 Clash API,必须启用 `access_control_allow_private_network`。 + +#### store_mode + +!!! failure "已在 sing-box 1.8.0 废弃" + + `store_mode` 已在 Clash API 中废弃,且默认启用当 `cache_file.enabled`。 + +将 Clash 模式存储在缓存文件中。 + +#### store_selected + +!!! failure "已在 sing-box 1.8.0 废弃" + + `store_selected` 已在 Clash API 中废弃,且默认启用当 `cache_file.enabled`。 + +!!! note "" + + 必须为目标出站设置标签。 + +将 `Selector` 中出站的选定的目标出站存储在缓存文件中。 + +#### store_fakeip + +!!! failure "已在 sing-box 1.8.0 废弃" + + `store_selected` 已在 Clash API 中废弃,且已迁移到 `cache_file.store_fakeip`。 + +将 fakeip 存储在缓存文件中。 + +#### cache_file + +!!! failure "已在 sing-box 1.8.0 废弃" + + `cache_file` 已在 Clash API 中废弃,且已迁移到 `cache_file.enabled` 和 `cache_file.path`。 + +缓存文件路径,默认使用`cache.db`。 + +#### cache_id + +!!! failure "已在 sing-box 1.8.0 废弃" + + `cache_id` 已在 Clash API 中废弃,且已迁移到 `cache_file.cache_id`。 + +缓存 ID。 + +如果不为空,配置特定的数据将使用由其键控的单独存储。 diff --git a/docs/configuration/experimental/index.md b/docs/configuration/experimental/index.md new file mode 100644 index 00000000..a1a515cf --- /dev/null +++ b/docs/configuration/experimental/index.md @@ -0,0 +1,26 @@ +# Experimental + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [cache_file](#cache_file) + :material-alert-decagram: [clash_api](#clash_api) + +### Structure + +```json +{ + "experimental": { + "cache_file": {}, + "clash_api": {}, + "v2ray_api": {} + } +} +``` + +### Fields + +| Key | Format | +|--------------|----------------------------| +| `cache_file` | [Cache File](./cache-file/) | +| `clash_api` | [Clash API](./clash-api/) | +| `v2ray_api` | [V2Ray API](./v2ray-api/) | \ No newline at end of file diff --git a/docs/configuration/experimental/index.zh.md b/docs/configuration/experimental/index.zh.md new file mode 100644 index 00000000..01246c44 --- /dev/null +++ b/docs/configuration/experimental/index.zh.md @@ -0,0 +1,26 @@ +# 实验性 + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [cache_file](#cache_file) + :material-alert-decagram: [clash_api](#clash_api) + +### 结构 + +```json +{ + "experimental": { + "cache_file": {}, + "clash_api": {}, + "v2ray_api": {} + } +} +``` + +### 字段 + +| 键 | 格式 | +|--------------|--------------------------| +| `cache_file` | [缓存文件](./cache-file/) | +| `clash_api` | [Clash API](./clash-api/) | +| `v2ray_api` | [V2Ray API](./v2ray-api/) | \ No newline at end of file diff --git a/docs/configuration/experimental/v2ray-api.md b/docs/configuration/experimental/v2ray-api.md new file mode 100644 index 00000000..4e23dea9 --- /dev/null +++ b/docs/configuration/experimental/v2ray-api.md @@ -0,0 +1,50 @@ +!!! quote "" + + V2Ray API is not included by default, see [Installation](/installation/build-from-source/#build-tags). + +### Structure + +```json +{ + "listen": "127.0.0.1:8080", + "stats": { + "enabled": true, + "inbounds": [ + "socks-in" + ], + "outbounds": [ + "proxy", + "direct" + ], + "users": [ + "sekai" + ] + } +} +``` + +### Fields + +#### listen + +gRPC API listening address. V2Ray API will be disabled if empty. + +#### stats + +Traffic statistics service settings. + +#### stats.enabled + +Enable statistics service. + +#### stats.inbounds + +Inbound list to count traffic. + +#### stats.outbounds + +Outbound list to count traffic. + +#### stats.users + +User list to count traffic. \ No newline at end of file diff --git a/docs/configuration/experimental/v2ray-api.zh.md b/docs/configuration/experimental/v2ray-api.zh.md new file mode 100644 index 00000000..87d5c95d --- /dev/null +++ b/docs/configuration/experimental/v2ray-api.zh.md @@ -0,0 +1,50 @@ +!!! quote "" + + 默认安装不包含 V2Ray API,参阅 [安装](/zh/installation/build-from-source/#构建标记)。 + +### 结构 + +```json +{ + "listen": "127.0.0.1:8080", + "stats": { + "enabled": true, + "inbounds": [ + "socks-in" + ], + "outbounds": [ + "proxy", + "direct" + ], + "users": [ + "sekai" + ] + } +} +``` + +### 字段 + +#### listen + +gRPC API 监听地址。如果为空,则禁用 V2Ray API。 + +#### stats + +流量统计服务设置。 + +#### stats.enabled + +启用统计服务。 + +#### stats.inbounds + +统计流量的入站列表。 + +#### stats.outbounds + +统计流量的出站列表。 + +#### stats.users + +统计流量的用户列表。 \ No newline at end of file diff --git a/docs/configuration/inbound/anytls.md b/docs/configuration/inbound/anytls.md new file mode 100644 index 00000000..f3780119 --- /dev/null +++ b/docs/configuration/inbound/anytls.md @@ -0,0 +1,61 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +### Structure + +```json +{ + "type": "anytls", + "tag": "anytls-in", + + ... // Listen Fields + + "users": [ + { + "name": "sekai", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ], + "padding_scheme": [], + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +==Required== + +AnyTLS users. + +#### padding_scheme + +AnyTLS padding scheme line array. + +Default padding scheme: + +```json +[ + "stop=8", + "0=30-30", + "1=100-400", + "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000", + "3=9-9,500-1000", + "4=500-1000", + "5=500-1000", + "6=500-1000", + "7=500-1000" +] +``` + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). diff --git a/docs/configuration/inbound/anytls.zh.md b/docs/configuration/inbound/anytls.zh.md new file mode 100644 index 00000000..8c3d1daf --- /dev/null +++ b/docs/configuration/inbound/anytls.zh.md @@ -0,0 +1,61 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +### 结构 + +```json +{ + "type": "anytls", + "tag": "anytls-in", + + ... // 监听字段 + + "users": [ + { + "name": "sekai", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ], + "padding_scheme": [], + "tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +==必填== + +AnyTLS 用户。 + +#### padding_scheme + +AnyTLS 填充方案行数组。 + +默认填充方案: + +```json +[ + "stop=8", + "0=30-30", + "1=100-400", + "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000", + "3=9-9,500-1000", + "4=500-1000", + "5=500-1000", + "6=500-1000", + "7=500-1000" +] +``` + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 diff --git a/docs/configuration/inbound/cloudflared.md b/docs/configuration/inbound/cloudflared.md new file mode 100644 index 00000000..e91d73e0 --- /dev/null +++ b/docs/configuration/inbound/cloudflared.md @@ -0,0 +1,89 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +`cloudflared` inbound runs an embedded Cloudflare Tunnel client and routes all +incoming tunnel traffic (TCP, UDP, ICMP) through sing-box's routing engine. + +### Structure + +```json +{ + "type": "cloudflared", + "tag": "", + + "token": "", + "ha_connections": 0, + "protocol": "", + "post_quantum": false, + "edge_ip_version": 0, + "datagram_version": "", + "grace_period": "", + "region": "", + "control_dialer": { + ... // Dial Fields + }, + "tunnel_dialer": { + ... // Dial Fields + } +} +``` + +### Fields + +#### token + +==Required== + +Base64-encoded tunnel token from the Cloudflare Zero Trust dashboard +(`Networks → Tunnels → Install connector`). + +#### ha_connections + +Number of high-availability connections to the Cloudflare edge. + +Capped by the number of discovered edge addresses. + +#### protocol + +Transport protocol for edge connections. + +One of `quic` `http2`. + +#### post_quantum + +Enable post-quantum key exchange on the control connection. + +#### edge_ip_version + +IP version used when connecting to the Cloudflare edge. + +One of `0` (automatic) `4` `6`. + +#### datagram_version + +Datagram protocol version used for UDP proxying over QUIC. + +One of `v2` `v3`. Only meaningful when `protocol` is `quic`. + +#### grace_period + +Graceful shutdown window for in-flight edge connections. + +#### region + +Cloudflare edge region selector. + +Conflict with endpoints embedded in `token`. + +#### control_dialer + +[Dial Fields](/configuration/shared/dial/) used when the tunnel client dials the +Cloudflare control plane. + +#### tunnel_dialer + +[Dial Fields](/configuration/shared/dial/) used when the tunnel client dials the +Cloudflare edge data plane. diff --git a/docs/configuration/inbound/cloudflared.zh.md b/docs/configuration/inbound/cloudflared.zh.md new file mode 100644 index 00000000..65aa7dcf --- /dev/null +++ b/docs/configuration/inbound/cloudflared.zh.md @@ -0,0 +1,89 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +`cloudflared` 入站运行一个内嵌的 Cloudflare Tunnel 客户端,并将所有传入的隧道流量 +(TCP、UDP、ICMP)通过 sing-box 的路由引擎转发。 + +### 结构 + +```json +{ + "type": "cloudflared", + "tag": "", + + "token": "", + "ha_connections": 0, + "protocol": "", + "post_quantum": false, + "edge_ip_version": 0, + "datagram_version": "", + "grace_period": "", + "region": "", + "control_dialer": { + ... // 拨号字段 + }, + "tunnel_dialer": { + ... // 拨号字段 + } +} +``` + +### 字段 + +#### token + +==必填== + +来自 Cloudflare Zero Trust 仪表板的 Base64 编码隧道令牌 +(`Networks → Tunnels → Install connector`)。 + +#### ha_connections + +到 Cloudflare edge 的高可用连接数。 + +上限为已发现的 edge 地址数量。 + +#### protocol + +edge 连接使用的传输协议。 + +`quic` `http2` 之一。 + +#### post_quantum + +在控制连接上启用后量子密钥交换。 + +#### edge_ip_version + +连接 Cloudflare edge 时使用的 IP 版本。 + +`0`(自动)`4` `6` 之一。 + +#### datagram_version + +通过 QUIC 进行 UDP 代理时使用的数据报协议版本。 + +`v2` `v3` 之一。仅在 `protocol` 为 `quic` 时有效。 + +#### grace_period + +正在处理的 edge 连接的优雅关闭窗口。 + +#### region + +Cloudflare edge 区域选择器。 + +与 `token` 中嵌入的 endpoint 冲突。 + +#### control_dialer + +隧道客户端拨向 Cloudflare 控制面时使用的 +[拨号字段](/zh/configuration/shared/dial/)。 + +#### tunnel_dialer + +隧道客户端拨向 Cloudflare edge 数据面时使用的 +[拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/inbound/direct.md b/docs/configuration/inbound/direct.md new file mode 100644 index 00000000..6dc93578 --- /dev/null +++ b/docs/configuration/inbound/direct.md @@ -0,0 +1,36 @@ +`direct` inbound is a tunnel server. + +### Structure + +```json +{ + "type": "direct", + "tag": "direct-in", + + ... // Listen Fields + + "network": "udp", + "override_address": "1.0.0.1", + "override_port": 53 +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### network + +Listen network, one of `tcp` `udp`. + +Both if empty. + +#### override_address + +Override the connection destination address. + +#### override_port + +Override the connection destination port. \ No newline at end of file diff --git a/docs/configuration/inbound/direct.zh.md b/docs/configuration/inbound/direct.zh.md new file mode 100644 index 00000000..a3861a53 --- /dev/null +++ b/docs/configuration/inbound/direct.zh.md @@ -0,0 +1,37 @@ +`direct` 入站是一个隧道服务器。 + +### 结构 + +```json +{ + "type": "direct", + "tag": "direct-in", + + ... // 监听字段 + + "network": "udp", + "override_address": "1.0.0.1", + "override_port": 53 +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### network + +监听的网络协议,`tcp` `udp` 之一。 + +默认所有。 + +#### override_address + +覆盖连接目标地址。 + +#### override_port + +覆盖连接目标端口。 + diff --git a/docs/configuration/inbound/http.md b/docs/configuration/inbound/http.md new file mode 100644 index 00000000..00343e22 --- /dev/null +++ b/docs/configuration/inbound/http.md @@ -0,0 +1,47 @@ +### Structure + +```json +{ + "type": "http", + "tag": "http-in", + + ... // Listen Fields + + "users": [ + { + "username": "admin", + "password": "admin" + } + ], + "tls": {}, + "set_system_proxy": false +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### users + +HTTP users. + +No authentication required if empty. + +#### set_system_proxy + +!!! quote "" + + Only supported on Linux, Android, Windows, and macOS. + +!!! warning "" + + To work on Android and Apple platforms without privileges, use tun.platform.http_proxy instead. + +Automatically set system proxy configuration when start and clean up when stop. diff --git a/docs/configuration/inbound/http.zh.md b/docs/configuration/inbound/http.zh.md new file mode 100644 index 00000000..e1dd876b --- /dev/null +++ b/docs/configuration/inbound/http.zh.md @@ -0,0 +1,47 @@ +### 结构 + +```json +{ + "type": "http", + "tag": "http-in", + + ... // 监听字段 + + "users": [ + { + "username": "admin", + "password": "admin" + } + ], + "tls": {}, + "set_system_proxy": false +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +#### users + +HTTP 用户 + +如果为空则不需要验证。 + +#### set_system_proxy + +!!! quote "" + + 仅支持 Linux、Android、Windows 和 macOS。 + +!!! warning "" + + 要在无特权的 Android 和 iOS 上工作,请改用 tun.platform.http_proxy。 + +启动时自动设置系统代理,停止时自动清理。 \ No newline at end of file diff --git a/docs/configuration/inbound/hysteria.md b/docs/configuration/inbound/hysteria.md new file mode 100644 index 00000000..4725aafc --- /dev/null +++ b/docs/configuration/inbound/hysteria.md @@ -0,0 +1,107 @@ +### Structure + +```json +{ + "type": "hysteria", + "tag": "hysteria-in", + + ... // Listen Fields + + "up": "100 Mbps", + "up_mbps": 100, + "down": "100 Mbps", + "down_mbps": 100, + "obfs": "fuck me till the daylight", + + "users": [ + { + "name": "sekai", + "auth": "", + "auth_str": "password" + } + ], + + "recv_window_conn": 0, + "recv_window_client": 0, + "max_conn_client": 0, + "disable_mtu_discovery": false, + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### up, down + +==Required== + +Format: `[Integer] [Unit]` e.g. `100 Mbps, 640 KBps, 2 Gbps` + +Supported units (case sensitive, b = bits, B = bytes, 8b=1B): + + bps (bits per second) + Bps (bytes per second) + Kbps (kilobits per second) + KBps (kilobytes per second) + Mbps (megabits per second) + MBps (megabytes per second) + Gbps (gigabits per second) + GBps (gigabytes per second) + Tbps (terabits per second) + TBps (terabytes per second) + +#### up_mbps, down_mbps + +==Required== + +`up, down` in Mbps. + +#### obfs + +Obfuscated password. + +#### users + +Hysteria users + +#### users.auth + +Authentication password, in base64. + +#### users.auth_str + +Authentication password. + +#### recv_window_conn + +The QUIC stream-level flow control window for receiving data. + +`15728640 (15 MB/s)` will be used if empty. + +#### recv_window_client + +The QUIC connection-level flow control window for receiving data. + +`67108864 (64 MB/s)` will be used if empty. + +#### max_conn_client + +The maximum number of QUIC concurrent bidirectional streams that a peer is allowed to open. + +`1024` will be used if empty. + +#### disable_mtu_discovery + +Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size. + +Force enabled on for systems other than Linux and Windows (according to upstream). + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). \ No newline at end of file diff --git a/docs/configuration/inbound/hysteria.zh.md b/docs/configuration/inbound/hysteria.zh.md new file mode 100644 index 00000000..561d7102 --- /dev/null +++ b/docs/configuration/inbound/hysteria.zh.md @@ -0,0 +1,107 @@ +### 结构 + +```json +{ + "type": "hysteria", + "tag": "hysteria-in", + + ... // 监听字段 + + "up": "100 Mbps", + "up_mbps": 100, + "down": "100 Mbps", + "down_mbps": 100, + "obfs": "fuck me till the daylight", + + "users": [ + { + "name": "sekai", + "auth": "", + "auth_str": "password" + } + ], + + "recv_window_conn": 0, + "recv_window_client": 0, + "max_conn_client": 0, + "disable_mtu_discovery": false, + "tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### up, down + +==必填== + +格式: `[Integer] [Unit]` 例如: `100 Mbps, 640 KBps, 2 Gbps` + +支持的单位 (大小写敏感, b = bits, B = bytes, 8b=1B): + + bps (bits per second) + Bps (bytes per second) + Kbps (kilobits per second) + KBps (kilobytes per second) + Mbps (megabits per second) + MBps (megabytes per second) + Gbps (gigabits per second) + GBps (gigabytes per second) + Tbps (terabits per second) + TBps (terabytes per second) + +#### up_mbps, down_mbps + +==必填== + +以 Mbps 为单位的 `up, down`。 + +#### obfs + +混淆密码。 + +#### users + +Hysteria 用户 + +#### users.auth + +base64 编码的认证密码。 + +#### users.auth_str + +认证密码。 + +#### recv_window_conn + +用于接收数据的 QUIC 流级流控制窗口。 + +默认 `15728640 (15 MB/s)`。 + +#### recv_window_client + +用于接收数据的 QUIC 连接级流控制窗口。 + +默认 `67108864 (64 MB/s)`。 + +#### max_conn_client + +允许对等点打开的 QUIC 并发双向流的最大数量。 + +默认 `1024`。 + +#### disable_mtu_discovery + +禁用路径 MTU 发现 (RFC 8899)。 数据包的大小最多为 1252 (IPv4) / 1232 (IPv6) 字节。 + +强制为 Linux 和 Windows 以外的系统启用(根据上游)。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/inbound/hysteria2.md b/docs/configuration/inbound/hysteria2.md new file mode 100644 index 00000000..8426be24 --- /dev/null +++ b/docs/configuration/inbound/hysteria2.md @@ -0,0 +1,159 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [bbr_profile](#bbr_profile) + +!!! quote "Changes in sing-box 1.11.0" + + :material-alert: [masquerade](#masquerade) + :material-alert: [ignore_client_bandwidth](#ignore_client_bandwidth) + +### Structure + +```json +{ + "type": "hysteria2", + "tag": "hy2-in", + + ... // Listen Fields + + "up_mbps": 100, + "down_mbps": 100, + "obfs": { + "type": "salamander", + "password": "cry_me_a_r1ver" + }, + "users": [ + { + "name": "tobyxdd", + "password": "goofy_ahh_password" + } + ], + "ignore_client_bandwidth": false, + "tls": {}, + "masquerade": "", // or {} + "bbr_profile": "", + "brutal_debug": false +} +``` + +!!! warning "Difference from official Hysteria2" + + The official program supports an authentication method called **userpass**, + which essentially uses a combination of `:` as the actual password, + while sing-box does not provide this alias. + To use sing-box with the official program, you need to fill in that combination as the actual password. + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### up_mbps, down_mbps + +Max bandwidth, in Mbps. + +Not limited if empty. + +Conflict with `ignore_client_bandwidth`. + +#### obfs.type + +QUIC traffic obfuscator type, only available with `salamander`. + +Disabled if empty. + +#### obfs.password + +QUIC traffic obfuscator password. + +#### users + +Hysteria2 users + +#### users.password + +Authentication password + +#### ignore_client_bandwidth + +*When `up_mbps` and `down_mbps` are not set*: + +Commands clients to use the BBR CC instead of Hysteria CC. + +*When `up_mbps` and `down_mbps` are set*: + +Deny clients to use the BBR CC. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### masquerade + +HTTP3 server behavior (URL string configuration) when authentication fails. + +| Scheme | Example | Description | +|--------------|-------------------------|--------------------| +| `file` | `file:///var/www` | As a file server | +| `http/https` | `http://127.0.0.1:8080` | As a reverse proxy | + +Conflict with `masquerade.type`. + +A 404 page will be returned if masquerade is not configured. + +#### masquerade.type + +HTTP3 server behavior (Object configuration) when authentication fails. + +| Type | Description | Fields | +|----------|-----------------------------|-------------------------------------| +| `file` | As a file server | `directory` | +| `proxy` | As a reverse proxy | `url`, `rewrite_host` | +| `string` | Reply with a fixed response | `status_code`, `headers`, `content` | + +Conflict with `masquerade`. + +A 404 page will be returned if masquerade is not configured. + +#### masquerade.directory + +File server root directory. + +#### masquerade.url + +Reverse proxy target URL. + +#### masquerade.rewrite_host + +Rewrite the `Host` header to the target URL. + +#### masquerade.status_code + +Fixed response status code. + +#### masquerade.headers + +Fixed response headers. + +#### masquerade.content + +Fixed response content. + +#### bbr_profile + +!!! question "Since sing-box 1.14.0" + +BBR congestion control algorithm profile, one of `conservative` `standard` `aggressive`. + +`standard` is used by default. + +#### brutal_debug + +Enable debug information logging for Hysteria Brutal CC. diff --git a/docs/configuration/inbound/hysteria2.zh.md b/docs/configuration/inbound/hysteria2.zh.md new file mode 100644 index 00000000..0c5e918e --- /dev/null +++ b/docs/configuration/inbound/hysteria2.zh.md @@ -0,0 +1,156 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [bbr_profile](#bbr_profile) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-alert: [masquerade](#masquerade) + :material-alert: [ignore_client_bandwidth](#ignore_client_bandwidth) + +### 结构 + +```json +{ + "type": "hysteria2", + "tag": "hy2-in", + + ... // 监听字段 + + "up_mbps": 100, + "down_mbps": 100, + "obfs": { + "type": "salamander", + "password": "cry_me_a_r1ver" + }, + "users": [ + { + "name": "tobyxdd", + "password": "goofy_ahh_password" + } + ], + "ignore_client_bandwidth": false, + "tls": {}, + "masquerade": "", // 或 {} + "bbr_profile": "", + "brutal_debug": false +} +``` + +!!! warning "与官方 Hysteria2 的区别" + + 官方程序支持一种名为 **userpass** 的验证方式, + 本质上是将用户名与密码的组合 `:` 作为实际上的密码,而 sing-box 不提供此别名。 + 要将 sing-box 与官方程序一起使用, 您需要填写该组合作为实际密码。 + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### up_mbps, down_mbps + +支持的速率,默认不限制。 + +与 `ignore_client_bandwidth` 冲突。 + +#### obfs.type + +QUIC 流量混淆器类型,仅可设为 `salamander`。 + +如果为空则禁用。 + +#### obfs.password + +QUIC 流量混淆器密码. + +#### users + +Hysteria 用户 + +#### users.password + +认证密码。 + +#### ignore_client_bandwidth + +*当 `up_mbps` 和 `down_mbps` 未设定时*: + +命令客户端使用 BBR 拥塞控制算法而不是 Hysteria CC。 + +*当 `up_mbps` 和 `down_mbps` 已设定时*: + +禁止客户端使用 BBR 拥塞控制算法。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +#### masquerade + +HTTP3 服务器认证失败时的行为 (URL 字符串配置)。 + +| Scheme | 示例 | 描述 | +|--------------|-------------------------|---------| +| `file` | `file:///var/www` | 作为文件服务器 | +| `http/https` | `http://127.0.0.1:8080` | 作为反向代理 | + +如果 masquerade 未配置,则返回 404 页。 + +与 `masquerade.type` 冲突。 + +#### masquerade.type + +HTTP3 服务器认证失败时的行为 (对象配置)。 + +| Type | 描述 | 字段 | +|----------|---------|-------------------------------------| +| `file` | 作为文件服务器 | `directory` | +| `proxy` | 作为反向代理 | `url`, `rewrite_host` | +| `string` | 返回固定响应 | `status_code`, `headers`, `content` | + +如果 masquerade 未配置,则返回 404 页。 + +与 `masquerade` 冲突。 + +#### masquerade.directory + +文件服务器根目录。 + +#### masquerade.url + +反向代理目标 URL。 + +#### masquerade.rewrite_host + +重写请求头中的 Host 字段到目标 URL。 + +#### masquerade.status_code + +固定响应状态码。 + +#### masquerade.headers + +固定响应头。 + +#### masquerade.content + +固定响应内容。 + +#### bbr_profile + +!!! question "自 sing-box 1.14.0 起" + +BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 + +默认使用 `standard`。 + +#### brutal_debug + +启用 Hysteria Brutal CC 的调试信息日志记录。 diff --git a/docs/configuration/inbound/index.md b/docs/configuration/inbound/index.md new file mode 100644 index 00000000..274a3780 --- /dev/null +++ b/docs/configuration/inbound/index.md @@ -0,0 +1,41 @@ +# Inbound + +### Structure + +```json +{ + "inbounds": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### Fields + +| Type | Format | Injectable | +|---------------|-------------------------------|------------------| +| `direct` | [Direct](./direct/) | :material-close: | +| `mixed` | [Mixed](./mixed/) | TCP | +| `socks` | [SOCKS](./socks/) | TCP | +| `http` | [HTTP](./http/) | TCP | +| `shadowsocks` | [Shadowsocks](./shadowsocks/) | TCP | +| `vmess` | [VMess](./vmess/) | TCP | +| `trojan` | [Trojan](./trojan/) | TCP | +| `naive` | [Naive](./naive/) | :material-close: | +| `hysteria` | [Hysteria](./hysteria/) | :material-close: | +| `shadowtls` | [ShadowTLS](./shadowtls/) | TCP | +| `tuic` | [TUIC](./tuic/) | :material-close: | +| `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | +| `vless` | [VLESS](./vless/) | TCP | +| `anytls` | [AnyTLS](./anytls/) | TCP | +| `tun` | [Tun](./tun/) | :material-close: | +| `redirect` | [Redirect](./redirect/) | :material-close: | +| `tproxy` | [TProxy](./tproxy/) | :material-close: | +| `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: | + +#### tag + +The tag of the inbound. \ No newline at end of file diff --git a/docs/configuration/inbound/index.zh.md b/docs/configuration/inbound/index.zh.md new file mode 100644 index 00000000..99f8df3b --- /dev/null +++ b/docs/configuration/inbound/index.zh.md @@ -0,0 +1,41 @@ +# 入站 + +### 结构 + +```json +{ + "inbounds": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### 字段 + +| 类型 | 格式 | 注入支持 | +|---------------|-------------------------------|------------------| +| `direct` | [Direct](./direct/) | :material-close: | +| `mixed` | [Mixed](./mixed/) | TCP | +| `socks` | [SOCKS](./socks/) | TCP | +| `http` | [HTTP](./http/) | TCP | +| `shadowsocks` | [Shadowsocks](./shadowsocks/) | TCP | +| `vmess` | [VMess](./vmess/) | TCP | +| `trojan` | [Trojan](./trojan/) | TCP | +| `naive` | [Naive](./naive/) | :material-close: | +| `hysteria` | [Hysteria](./hysteria/) | :material-close: | +| `shadowtls` | [ShadowTLS](./shadowtls/) | TCP | +| `tuic` | [TUIC](./tuic/) | :material-close: | +| `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | +| `vless` | [VLESS](./vless/) | TCP | +| `anytls` | [AnyTLS](./anytls/) | TCP | +| `tun` | [Tun](./tun/) | :material-close: | +| `redirect` | [Redirect](./redirect/) | :material-close: | +| `tproxy` | [TProxy](./tproxy/) | :material-close: | +| `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: | + +#### tag + +入站的标签。 \ No newline at end of file diff --git a/docs/configuration/inbound/mixed.md b/docs/configuration/inbound/mixed.md new file mode 100644 index 00000000..e9deec75 --- /dev/null +++ b/docs/configuration/inbound/mixed.md @@ -0,0 +1,44 @@ +`mixed` inbound is a socks4, socks4a, socks5 and http server. + +### Structure + +```json +{ + "type": "mixed", + "tag": "mixed-in", + + ... // Listen Fields + + "users": [ + { + "username": "admin", + "password": "admin" + } + ], + "set_system_proxy": false +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +SOCKS and HTTP users. + +No authentication required if empty. + +#### set_system_proxy + +!!! quote "" + + Only supported on Linux, Android, Windows, and macOS. + +!!! warning "" + + To work on Android and Apple platforms without privileges, use tun.platform.http_proxy instead. + +Automatically set system proxy configuration when start and clean up when stop. diff --git a/docs/configuration/inbound/mixed.zh.md b/docs/configuration/inbound/mixed.zh.md new file mode 100644 index 00000000..448c66b4 --- /dev/null +++ b/docs/configuration/inbound/mixed.zh.md @@ -0,0 +1,44 @@ +`mixed` 入站是一个 socks4, socks4a, socks5 和 http 服务器. + +### 结构 + +```json +{ + "type": "mixed", + "tag": "mixed-in", + + ... // 监听字段 + + "users": [ + { + "username": "admin", + "password": "admin" + } + ], + "set_system_proxy": false +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +SOCKS 和 HTTP 用户 + +如果为空则不需要验证。 + +#### set_system_proxy + +!!! quote "" + + 仅支持 Linux、Android、Windows 和 macOS。 + +!!! warning "" + + 要在无特权的 Android 和 iOS 上工作,请改用 tun.platform.http_proxy。 + +启动时自动设置系统代理,停止时自动清理。 \ No newline at end of file diff --git a/docs/configuration/inbound/naive.md b/docs/configuration/inbound/naive.md new file mode 100644 index 00000000..a360fa95 --- /dev/null +++ b/docs/configuration/inbound/naive.md @@ -0,0 +1,63 @@ +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [quic_congestion_control](#quic_congestion_control) + +### Structure + +```json +{ +"type": "naive", +"tag": "naive-in", +"network": "udp", +... +// Listen Fields + +"users": [ +{ +"username": "sekai", +"password": "password" +} +], +"quic_congestion_control": "", +"tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### network + +Listen network, one of `tcp` `udp`. + +Both if empty. + +#### users + +==Required== + +Naive users. + +#### quic_congestion_control + +!!! question "Since sing-box 1.13.0" + +QUIC congestion control algorithm. + +| Algorithm | Description | +|----------------|---------------------------------| +| `bbr` | BBR | +| `bbr_standard` | BBR (Standard version) | +| `bbr2` | BBRv2 | +| `bbr2_variant` | BBRv2 (An experimental variant) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +`bbr` is used by default (the default of QUICHE, used by Chromium which NaiveProxy is based on). + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). \ No newline at end of file diff --git a/docs/configuration/inbound/naive.zh.md b/docs/configuration/inbound/naive.zh.md new file mode 100644 index 00000000..0984d310 --- /dev/null +++ b/docs/configuration/inbound/naive.zh.md @@ -0,0 +1,63 @@ +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [quic_congestion_control](#quic_congestion_control) + +### 结构 + +```json +{ +"type": "naive", +"tag": "naive-in", +"network": "udp", + +... // 监听字段 + +"users": [ +{ +"username": "sekai", +"password": "password" +} +], +"quic_congestion_control": "", +"tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### network + +监听的网络协议,`tcp` `udp` 之一。 + +默认所有。 + +#### users + +==必填== + +Naive 用户。 + +#### quic_congestion_control + +!!! question "Since sing-box 1.13.0" + +QUIC 拥塞控制算法。 + +| 算法 | 描述 | +|----------------|--------------------| +| `bbr` | BBR | +| `bbr_standard` | BBR (标准版) | +| `bbr2` | BBRv2 | +| `bbr2_variant` | BBRv2 (一种试验变体) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +默认使用 `bbr`(NaiveProxy 基于的 Chromium 使用的 QUICHE 的默认值)。 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/inbound/redirect.md b/docs/configuration/inbound/redirect.md new file mode 100644 index 00000000..50a5bacd --- /dev/null +++ b/docs/configuration/inbound/redirect.md @@ -0,0 +1,18 @@ +!!! quote "" + + Only supported on Linux and macOS. + +### Structure + +```json +{ + "type": "redirect", + "tag": "redirect-in", + + ... // Listen Fields +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. diff --git a/docs/configuration/inbound/redirect.zh.md b/docs/configuration/inbound/redirect.zh.md new file mode 100644 index 00000000..a03049e5 --- /dev/null +++ b/docs/configuration/inbound/redirect.zh.md @@ -0,0 +1,17 @@ +!!! quote "" + + 仅支持 Linux 和 macOS。 + +### 结构 + +```json +{ + "type": "redirect", + "tag": "redirect-in", + + ... // 监听字段 +} +``` +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 diff --git a/docs/configuration/inbound/shadowsocks.md b/docs/configuration/inbound/shadowsocks.md new file mode 100644 index 00000000..e5115857 --- /dev/null +++ b/docs/configuration/inbound/shadowsocks.md @@ -0,0 +1,96 @@ +### Structure + +```json +{ + "type": "shadowsocks", + "tag": "ss-in", + + ... // Listen Fields + + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "managed": false, + "multiplex": {} +} +``` + +### Multi-User Structure + +```json +{ + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "users": [ + { + "name": "sekai", + "password": "PCD2Z4o12bKUoFa3cC97Hw==" + } + ], + "multiplex": {} +} +``` + +### Relay Structure + +```json +{ + "type": "shadowsocks", + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "destinations": [ + { + "name": "test", + "server": "example.com", + "server_port": 8080, + "password": "PCD2Z4o12bKUoFa3cC97Hw==" + } + ], + "multiplex": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### network + +Listen network, one of `tcp` `udp`. + +Both if empty. + +#### method + +==Required== + +| Method | Key Length | +|-------------------------------|------------| +| 2022-blake3-aes-128-gcm | 16 | +| 2022-blake3-aes-256-gcm | 32 | +| 2022-blake3-chacha20-poly1305 | 32 | +| none | / | +| aes-128-gcm | / | +| aes-192-gcm | / | +| aes-256-gcm | / | +| chacha20-ietf-poly1305 | / | +| xchacha20-ietf-poly1305 | / | + +#### password + +==Required== + +| Method | Password Format | +|---------------|------------------------------------------------| +| none | / | +| 2022 methods | `sing-box generate rand --base64 ` | +| other methods | any string | + +#### managed + +Defaults to `false`. Enable this when the inbound is managed by the [SSM API](/configuration/service/ssm-api) for dynamic user. + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#inbound) for details. diff --git a/docs/configuration/inbound/shadowsocks.zh.md b/docs/configuration/inbound/shadowsocks.zh.md new file mode 100644 index 00000000..a991b0c3 --- /dev/null +++ b/docs/configuration/inbound/shadowsocks.zh.md @@ -0,0 +1,96 @@ +### 结构 + +```json +{ + "type": "shadowsocks", + "tag": "ss-in", + + ... // 监听字段 + + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "managed": false, + "multiplex": {} +} +``` + +### 多用户结构 + +```json +{ + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "users": [ + { + "name": "sekai", + "password": "PCD2Z4o12bKUoFa3cC97Hw==" + } + ], + "multiplex": {} +} +``` + +### 中转结构 + +```json +{ + "type": "shadowsocks", + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "destinations": [ + { + "name": "test", + "server": "example.com", + "server_port": 8080, + "password": "PCD2Z4o12bKUoFa3cC97Hw==" + } + ], + "multiplex": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### network + +监听的网络协议,`tcp` `udp` 之一。 + +默认所有。 + +#### method + +==必填== + +| 方法 | 密钥长度 | +|-------------------------------|------| +| 2022-blake3-aes-128-gcm | 16 | +| 2022-blake3-aes-256-gcm | 32 | +| 2022-blake3-chacha20-poly1305 | 32 | +| none | / | +| aes-128-gcm | / | +| aes-192-gcm | / | +| aes-256-gcm | / | +| chacha20-ietf-poly1305 | / | +| xchacha20-ietf-poly1305 | / | + +#### password + +==必填== + +| 方法 | 密码格式 | +|---------------|------------------------------------------| +| none | / | +| 2022 methods | `sing-box generate rand --base64 <密钥长度>` | +| other methods | 任意字符串 | + +#### managed + +默认为 `false`。当该入站需要由 [SSM API](/zh/configuration/service/ssm-api) 管理用户时必须启用此字段。 + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 diff --git a/docs/configuration/inbound/shadowtls.md b/docs/configuration/inbound/shadowtls.md new file mode 100644 index 00000000..9dbf1dd5 --- /dev/null +++ b/docs/configuration/inbound/shadowtls.md @@ -0,0 +1,107 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [wildcard_sni](#wildcard_sni) + +### Structure + +```json +{ + "type": "shadowtls", + "tag": "st-in", + + ... // Listen Fields + + "version": 3, + "password": "fuck me till the daylight", + "users": [ + { + "name": "sekai", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ], + "handshake": { + "server": "google.com", + "server_port": 443, + + ... // Dial Fields + }, + "handshake_for_server_name": { + "example.com": { + "server": "example.com", + "server_port": 443, + + ... // Dial Fields + } + }, + "strict_mode": false, + "wildcard_sni": "" +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### version + +ShadowTLS protocol version. + +| Value | Protocol Version | +|---------------|-----------------------------------------------------------------------------------------| +| `1` (default) | [ShadowTLS v1](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v1) | +| `2` | [ShadowTLS v2](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v2) | +| `3` | [ShadowTLS v3](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-v3-en.md) | + +#### password + +ShadowTLS password. + +Only available in the ShadowTLS protocol 2. + +#### users + +ShadowTLS users. + +Only available in the ShadowTLS protocol 3. + +#### handshake + +==Required== + +When `wildcard_sni` is configured to `all`, the server address is optional. + +Handshake server address and [Dial Fields](/configuration/shared/dial/). + +#### handshake_for_server_name + +Handshake server address and [Dial Fields](/configuration/shared/dial/) for specific server name. + +Only available in the ShadowTLS protocol 2/3. + +#### strict_mode + +ShadowTLS strict mode. + +Only available in the ShadowTLS protocol 3. + +#### wildcard_sni + +!!! question "Since sing-box 1.12.0" + +ShadowTLS wildcard SNI mode. + +Available values are: + +* `off`: (default) Disabled. +* `authed`: Authenticated connections will have their destination overwritten to `(servername):443` +* `all`: All connections will have their destination overwritten to `(servername):443` + +Additionally, connections matching `handshake_for_server_name` are not affected. + +Only available in the ShadowTLS protocol 3. diff --git a/docs/configuration/inbound/shadowtls.zh.md b/docs/configuration/inbound/shadowtls.zh.md new file mode 100644 index 00000000..be860051 --- /dev/null +++ b/docs/configuration/inbound/shadowtls.zh.md @@ -0,0 +1,107 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [wildcard_sni](#wildcard_sni) + +### 结构 + +```json +{ + "type": "shadowtls", + "tag": "st-in", + + ... // 监听字段 + + "version": 3, + "password": "fuck me till the daylight", + "users": [ + { + "name": "sekai", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ], + "handshake": { + "server": "google.com", + "server_port": 443, + + ... // 拨号字段 + }, + "handshake_for_server_name": { + "example.com": { + "server": "example.com", + "server_port": 443, + + ... // 拨号字段 + } + }, + "strict_mode": false, + "wildcard_sni": "" +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### version + +ShadowTLS 协议版本。 + +| 值 | 协议版本 | +|---------------|-----------------------------------------------------------------------------------------| +| `1` (default) | [ShadowTLS v1](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v1) | +| `2` | [ShadowTLS v2](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v2) | +| `3` | [ShadowTLS v3](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-v3-en.md) | + +#### password + +ShadowTLS 密码。 + +仅在 ShadowTLS 协议版本 2 中可用。 + +#### users + +ShadowTLS 用户。 + +仅在 ShadowTLS 协议版本 3 中可用。 + +#### handshake + +==必填== + +握手服务器地址和 [拨号参数](/zh/configuration/shared/dial/)。 + +#### handshake_for_server_name + +==必填== + +对于特定服务器名称的握手服务器地址和 [拨号参数](/zh/configuration/shared/dial/)。 + +仅在 ShadowTLS 协议版本 2/3 中可用。 + +#### strict_mode + +ShadowTLS 严格模式。 + +仅在 ShadowTLS 协议版本 3 中可用。 + +#### wildcard_sni + +!!! question "自 sing-box 1.12.0 起" + +ShadowTLS 通配符 SNI 模式。 + +可用值: + +* `off`:(默认)禁用。 +* `authed`:已认证的连接的目标将被重写为 `(servername):443`。 +* `all`:所有连接的目标将被重写为 `(servername):443`。 + +此外,匹配 `handshake_for_server_name` 的连接不受影响。 + +仅在 ShadowTLS 协议 3 中可用。 diff --git a/docs/configuration/inbound/socks.md b/docs/configuration/inbound/socks.md new file mode 100644 index 00000000..4937f390 --- /dev/null +++ b/docs/configuration/inbound/socks.md @@ -0,0 +1,31 @@ +`socks` inbound is a socks4, socks4a, socks5 server. + +### Structure + +```json +{ + "type": "socks", + "tag": "socks-in", + + ... // Listen Fields + + "users": [ + { + "username": "admin", + "password": "admin" + } + ] +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +SOCKS users. + +No authentication required if empty. diff --git a/docs/configuration/inbound/socks.zh.md b/docs/configuration/inbound/socks.zh.md new file mode 100644 index 00000000..90314526 --- /dev/null +++ b/docs/configuration/inbound/socks.zh.md @@ -0,0 +1,31 @@ +`socks` 入站是一个 socks4, socks4a 和 socks5 服务器. + +### 结构 + +```json +{ + "type": "socks", + "tag": "socks-in", + + ... // 监听字段 + + "users": [ + { + "username": "admin", + "password": "admin" + } + ] +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +SOCKS 用户 + +如果为空则不需要验证。 diff --git a/docs/configuration/inbound/tproxy.md b/docs/configuration/inbound/tproxy.md new file mode 100644 index 00000000..42288537 --- /dev/null +++ b/docs/configuration/inbound/tproxy.md @@ -0,0 +1,28 @@ +!!! quote "" + + Only supported on Linux. + +### Structure + +```json +{ + "type": "tproxy", + "tag": "tproxy-in", + + ... // Listen Fields + + "network": "udp" +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### network + +Listen network, one of `tcp` `udp`. + +Both if empty. diff --git a/docs/configuration/inbound/tproxy.zh.md b/docs/configuration/inbound/tproxy.zh.md new file mode 100644 index 00000000..6e35ad5e --- /dev/null +++ b/docs/configuration/inbound/tproxy.zh.md @@ -0,0 +1,28 @@ +!!! quote "" + + 仅支持 Linux。 + +### 结构 + +```json +{ + "type": "tproxy", + "tag": "tproxy-in", + + ... // 监听字段 + + "network": "udp" +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### network + +监听的网络协议,`tcp` `udp` 之一。 + +默认所有。 diff --git a/docs/configuration/inbound/trojan.md b/docs/configuration/inbound/trojan.md new file mode 100644 index 00000000..e277236b --- /dev/null +++ b/docs/configuration/inbound/trojan.md @@ -0,0 +1,68 @@ +### Structure + +```json +{ + "type": "trojan", + "tag": "trojan-in", + + ... // Listen Fields + + "users": [ + { + "name": "sekai", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ], + "tls": {}, + "fallback": { + "server": "127.0.0.1", + "server_port": 8080 + }, + "fallback_for_alpn": { + "http/1.1": { + "server": "127.0.0.1", + "server_port": 8081 + } + }, + "multiplex": {}, + "transport": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +==Required== + +Trojan users. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### fallback + +!!! failure "" + + There is no evidence that GFW detects and blocks Trojan servers based on HTTP responses, and opening the standard http/s port on the server is a much bigger signature. + +Fallback server configuration. Disabled if `fallback` and `fallback_for_alpn` are empty. + +#### fallback_for_alpn + +Fallback server configuration for specified ALPN. + +If not empty, TLS fallback requests with ALPN not in this table will be rejected. + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#inbound) for details. + +#### transport + +V2Ray Transport configuration, see [V2Ray Transport](/configuration/shared/v2ray-transport/). diff --git a/docs/configuration/inbound/trojan.zh.md b/docs/configuration/inbound/trojan.zh.md new file mode 100644 index 00000000..d81b4c1d --- /dev/null +++ b/docs/configuration/inbound/trojan.zh.md @@ -0,0 +1,68 @@ +### 结构 + +```json +{ + "type": "trojan", + "tag": "trojan-in", + + ... // 监听字段 + + "users": [ + { + "name": "sekai", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ], + "tls": {}, + "fallback": { + "server": "127.0.0.1", + "server_port": 8080 + }, + "fallback_for_alpn": { + "http/1.1": { + "server": "127.0.0.1", + "server_port": 8081 + } + }, + "multiplex": {}, + "transport": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +==必填== + +Trojan 用户。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +#### fallback + +!!! failure "" + + 没有证据表明 GFW 基于 HTTP 响应检测并阻止 Trojan 服务器,并且在服务器上打开标准 http/s 端口是一个更大的特征。 + +回退服务器配置。如果 `fallback` 和 `fallback_for_alpn` 为空,则禁用回退。 + +#### fallback_for_alpn + +为 ALPN 指定回退服务器配置。 + +如果不为空,ALPN 不在此列表中的 TLS 回退请求将被拒绝。 + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 + +#### transport + +V2Ray 传输配置,参阅 [V2Ray 传输层](/zh/configuration/shared/v2ray-transport/)。 \ No newline at end of file diff --git a/docs/configuration/inbound/tuic.md b/docs/configuration/inbound/tuic.md new file mode 100644 index 00000000..8a2d8c7e --- /dev/null +++ b/docs/configuration/inbound/tuic.md @@ -0,0 +1,78 @@ +### Structure + +```json +{ + "type": "tuic", + "tag": "tuic-in", + + ... // Listen Fields + + "users": [ + { + "name": "sekai", + "uuid": "059032A9-7D40-4A96-9BB1-36823D848068", + "password": "hello" + } + ], + "congestion_control": "cubic", + "auth_timeout": "3s", + "zero_rtt_handshake": false, + "heartbeat": "10s", + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +TUIC users + +#### users.uuid + +==Required== + +TUIC user uuid + +#### users.password + +TUIC user password + +#### congestion_control + +QUIC congestion control algorithm + +One of: `cubic`, `new_reno`, `bbr` + +`cubic` is used by default. + +#### auth_timeout + +How long the server should wait for the client to send the authentication command + +`3s` is used by default. + +#### zero_rtt_handshake + +Enable 0-RTT QUIC connection handshake on the client side +This is not impacting much on the performance, as the protocol is fully multiplexed + +!!! warning "" + Disabling this is highly recommended, as it is vulnerable to replay attacks. + See [Attack of the clones](https://blog.cloudflare.com/even-faster-connection-establishment-with-quic-0-rtt-resumption/#attack-of-the-clones) + +#### heartbeat + +Interval for sending heartbeat packets for keeping the connection alive + +`10s` is used by default. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). \ No newline at end of file diff --git a/docs/configuration/inbound/tuic.zh.md b/docs/configuration/inbound/tuic.zh.md new file mode 100644 index 00000000..ae531635 --- /dev/null +++ b/docs/configuration/inbound/tuic.zh.md @@ -0,0 +1,78 @@ +### 结构 + +```json +{ + "type": "tuic", + "tag": "tuic-in", + + ... // 监听字段 + + "users": [ + { + "name": "sekai", + "uuid": "059032A9-7D40-4A96-9BB1-36823D848068", + "password": "hello" + } + ], + "congestion_control": "cubic", + "auth_timeout": "3s", + "zero_rtt_handshake": false, + "heartbeat": "10s", + "tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +TUIC 用户 + +#### users.uuid + +==必填== + +TUIC 用户 UUID + +#### users.password + +TUIC 用户密码 + +#### congestion_control + +QUIC 拥塞控制算法 + +可选值: `cubic`, `new_reno`, `bbr` + +默认使用 `cubic`。 + +#### auth_timeout + +服务器等待客户端发送认证命令的时间 + +默认使用 `3s`。 + +#### zero_rtt_handshake + +在客户端启用 0-RTT QUIC 连接握手 +这对性能影响不大,因为协议是完全复用的 + +!!! warning "" +强烈建议禁用此功能,因为它容易受到重放攻击。 +请参阅 [Attack of the clones](https://blog.cloudflare.com/even-faster-connection-establishment-with-quic-0-rtt-resumption/#attack-of-the-clones) + +#### heartbeat + +发送心跳包以保持连接存活的时间间隔 + +默认使用 `10s`。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md new file mode 100644 index 00000000..6dae06e1 --- /dev/null +++ b/docs/configuration/inbound/tun.md @@ -0,0 +1,635 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + +!!! quote "Changes in sing-box 1.13.3" + + :material-alert: [strict_route](#strict_route) + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) + :material-plus: [auto_redirect_nfqueue](#auto_redirect_nfqueue) + :material-plus: [exclude_mptcp](#exclude_mptcp) + :material-plus: [auto_redirect_iproute2_fallback_rule_index](#auto_redirect_iproute2_fallback_rule_index) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [loopback_address](#loopback_address) + +!!! quote "Changes in sing-box 1.11.0" + + :material-delete-alert: [gso](#gso) + :material-alert-decagram: [route_address_set](#stack) + :material-alert-decagram: [route_exclude_address_set](#stack) + +!!! quote "Changes in sing-box 1.10.0" + + :material-plus: [address](#address) + :material-delete-clock: [inet4_address](#inet4_address) + :material-delete-clock: [inet6_address](#inet6_address) + :material-plus: [route_address](#route_address) + :material-delete-clock: [inet4_route_address](#inet4_route_address) + :material-delete-clock: [inet6_route_address](#inet6_route_address) + :material-plus: [route_exclude_address](#route_address) + :material-delete-clock: [inet4_route_exclude_address](#inet4_route_exclude_address) + :material-delete-clock: [inet6_route_exclude_address](#inet6_route_exclude_address) + :material-plus: [iproute2_table_index](#iproute2_table_index) + :material-plus: [iproute2_rule_index](#iproute2_table_index) + :material-plus: [auto_redirect](#auto_redirect) + :material-plus: [auto_redirect_input_mark](#auto_redirect_input_mark) + :material-plus: [auto_redirect_output_mark](#auto_redirect_output_mark) + :material-plus: [route_address_set](#route_address_set) + :material-plus: [route_exclude_address_set](#route_address_set) + +!!! quote "Changes in sing-box 1.9.0" + + :material-plus: [platform.http_proxy.bypass_domain](#platformhttp_proxybypass_domain) + :material-plus: [platform.http_proxy.match_domain](#platformhttp_proxymatch_domain) + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [gso](#gso) + :material-alert-decagram: [stack](#stack) + +!!! quote "" + + Only supported on Linux, Windows and macOS. + +### Structure + +```json +{ + "type": "tun", + "tag": "tun-in", + "interface_name": "tun0", + "address": [ + "172.18.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "mtu": 9000, + "auto_route": true, + "iproute2_table_index": 2022, + "iproute2_rule_index": 9000, + "auto_redirect": true, + "auto_redirect_input_mark": "0x2023", + "auto_redirect_output_mark": "0x2024", + "auto_redirect_reset_mark": "0x2025", + "auto_redirect_nfqueue": 100, + "auto_redirect_iproute2_fallback_rule_index": 32768, + "exclude_mptcp": false, + "loopback_address": [ + "10.7.0.1" + ], + "strict_route": true, + "route_address": [ + "0.0.0.0/1", + "128.0.0.0/1", + "::/1", + "8000::/1" + ], + "route_exclude_address": [ + "192.168.0.0/16", + "fc00::/7" + ], + "route_address_set": [ + "geoip-cloudflare" + ], + "route_exclude_address_set": [ + "geoip-cn" + ], + "endpoint_independent_nat": false, + "udp_timeout": "5m", + "stack": "system", + "include_interface": [ + "lan0" + ], + "exclude_interface": [ + "lan1" + ], + "include_uid": [ + 0 + ], + "include_uid_range": [ + "1000:99999" + ], + "exclude_uid": [ + 1000 + ], + "exclude_uid_range": [ + "1000:99999" + ], + "include_android_user": [ + 0, + 10 + ], + "include_package": [ + "com.android.chrome" + ], + "exclude_package": [ + "com.android.captiveportallogin" + ], + "include_mac_address": [ + "00:11:22:33:44:55" + ], + "exclude_mac_address": [ + "66:77:88:99:aa:bb" + ], + "platform": { + "http_proxy": { + "enabled": false, + "server": "127.0.0.1", + "server_port": 8080, + "bypass_domain": [], + "match_domain": [] + } + }, + // Deprecated + "gso": false, + "inet4_address": [ + "172.19.0.1/30" + ], + "inet6_address": [ + "fdfe:dcba:9876::1/126" + ], + "inet4_route_address": [ + "0.0.0.0/1", + "128.0.0.0/1" + ], + "inet6_route_address": [ + "::/1", + "8000::/1" + ], + "inet4_route_exclude_address": [ + "192.168.0.0/16" + ], + "inet6_route_exclude_address": [ + "fc00::/7" + ], + ... + // Listen Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +!!! warning "" + + If tun is running in non-privileged mode, addresses and MTU will not be configured automatically, please make sure the settings are accurate. + +### Fields + +#### interface_name + +Virtual device name, automatically selected if empty. + +#### address + +!!! question "Since sing-box 1.10.0" + +IPv4 and IPv6 prefix for the tun interface. + +#### inet4_address + +!!! failure "Deprecated in sing-box 1.10.0" + + `inet4_address` is merged to `address` and will be removed in sing-box 1.12.0. + +IPv4 prefix for the tun interface. + +#### inet6_address + +!!! failure "Deprecated in sing-box 1.10.0" + + `inet6_address` is merged to `address` and will be removed in sing-box 1.12.0. + +IPv6 prefix for the tun interface. + +#### mtu + +The maximum transmission unit. + +#### gso + +!!! failure "Deprecated in sing-box 1.11.0" + + GSO has no advantages for transparent proxy scenarios, is deprecated and no longer works, and will be removed in sing-box 1.12.0. + +!!! question "Since sing-box 1.8.0" + +!!! quote "" + + Only supported on Linux with `auto_route` enabled. + +Enable generic segmentation offload. + +#### auto_route + +Set the default route to the Tun. + +!!! quote "" + + To avoid traffic loopback, set `route.auto_detect_interface` or `route.default_interface` or `outbound.bind_interface` + +!!! note "Use with Android VPN" + + By default, VPN takes precedence over tun. To make tun go through VPN, enable `route.override_android_vpn`. + +!!! note "Also enable `auto_redirect`" + + `auto_redirect` is always recommended on Linux, it provides better routing, higher performance (better than tproxy), and avoids conflicts between TUN and Docker bridge networks. + +#### iproute2_table_index + +!!! question "Since sing-box 1.10.0" + +Linux iproute2 table index generated by `auto_route`. + +`2022` is used by default. + +#### iproute2_rule_index + +!!! question "Since sing-box 1.10.0" + +Linux iproute2 rule start index generated by `auto_route`. + +`9000` is used by default. + +#### auto_redirect + +!!! question "Since sing-box 1.10.0" + +!!! quote "" + + Only supported on Linux with `auto_route` enabled. + +Improve TUN routing and performance using nftables. + +`auto_redirect` is always recommended on Linux, it provides better routing, +higher performance (better than tproxy), +and avoids conflicts between TUN and Docker bridge networks. + +Note that `auto_redirect` also works on Android, +but due to the lack of `nftables` and `ip6tables`, +only simple IPv4 TCP forwarding is performed. +To share your VPN connection over hotspot or repeater on Android, +use [VPNHotspot](https://github.com/Mygod/VPNHotspot). + +`auto_redirect` also automatically inserts compatibility rules +into the OpenWrt fw4 table, i.e. +it will work on routers without any extra configuration. + +Conflict with `route.default_mark` and `[dialOptions].routing_mark`. + +#### auto_redirect_input_mark + +!!! question "Since sing-box 1.10.0" + +Connection input mark used by `auto_redirect`. + +`0x2023` is used by default. + +#### auto_redirect_output_mark + +!!! question "Since sing-box 1.10.0" + +Connection output mark used by `auto_redirect`. + +`0x2024` is used by default. + +#### auto_redirect_reset_mark + +!!! question "Since sing-box 1.13.0" + +Connection reset mark used by `auto_redirect` pre-matching. + +`0x2025` is used by default. + +#### auto_redirect_nfqueue + +!!! question "Since sing-box 1.13.0" + +NFQueue number used by `auto_redirect` pre-matching. + +`100` is used by default. + +#### auto_redirect_iproute2_fallback_rule_index + +!!! question "Since sing-box 1.12.18" + +Linux iproute2 fallback rule index generated by `auto_redirect`. + +This rule is checked after system default rules (32766: main, 32767: default), +routing traffic to the sing-box table only when no route is found in system tables. + +`32768` is used by default. + +#### exclude_mptcp + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux with nftables and requires `auto_route` and `auto_redirect` enabled. + +MPTCP cannot be transparently proxied due to protocol limitations. + +Such traffic is usually created by Apple systems. + +When enabled, MPTCP connections will bypass sing-box and connect directly, otherwise, will be rejected to avoid errors by default. + +#### loopback_address + +!!! question "Since sing-box 1.12.0" + +Loopback addresses make TCP connections to the specified address connect to the source address. + +Setting option value to `10.7.0.1` achieves the same behavior as SideStore/StosVPN. + +When `auto_redirect` is enabled, the same behavior can be achieved for LAN devices (not just local) as a gateway. + +#### strict_route + +Enforce strict routing rules when `auto_route` is enabled: + +*In Linux*: + +* Let unsupported network unreachable +* For legacy reasons, when neither `strict_route` nor `auto_redirect` are enabled, all ICMP traffic will not go through TUN. +* When `auto_redirect` is enabled, `strict_route` also affects `SO_BINDTODEVICE` traffic: + * Enabled: `SO_BINDTODEVICE` traffic is redirected through sing-box. + * Disabled: `SO_BINDTODEVICE` traffic bypasses sing-box. + +*In Windows*: + +* Let unsupported network unreachable +* prevent DNS leak caused by + Windows' [ordinary multihomed DNS resolution behavior](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/dd197552%28v%3Dws.10%29) + +It may prevent some Windows applications (such as VirtualBox) from working properly in certain situations. + +#### route_address + +!!! question "Since sing-box 1.10.0" + +Use custom routes instead of default when `auto_route` is enabled. + +#### inet4_route_address + +!!! failure "Deprecated in sing-box 1.10.0" + +`inet4_route_address` is deprecated and will be removed in sing-box 1.12.0, please use [route_address](#route_address) +instead. + +Use custom routes instead of default when `auto_route` is enabled. + +#### inet6_route_address + +!!! failure "Deprecated in sing-box 1.10.0" + +`inet6_route_address` is deprecated and will be removed in sing-box 1.12.0, please use [route_address](#route_address) +instead. + +Use custom routes instead of default when `auto_route` is enabled. + +#### route_exclude_address + +!!! question "Since sing-box 1.10.0" + +Exclude custom routes when `auto_route` is enabled. + +#### inet4_route_exclude_address + +!!! failure "Deprecated in sing-box 1.10.0" + +`inet4_route_exclude_address` is deprecated and will be removed in sing-box 1.12.0, please +use [route_exclude_address](#route_exclude_address) instead. + +Exclude custom routes when `auto_route` is enabled. + +#### inet6_route_exclude_address + +!!! failure "Deprecated in sing-box 1.10.0" + +`inet6_route_exclude_address` is deprecated and will be removed in sing-box 1.12.0, please +use [route_exclude_address](#route_exclude_address) instead. + +Exclude custom routes when `auto_route` is enabled. + +#### route_address_set + +=== "With `auto_redirect` enabled" + + !!! question "Since sing-box 1.10.0" + + !!! quote "" + + Only supported on Linux with nftables and requires `auto_route` and `auto_redirect` enabled. + + Add the destination IP CIDR rules in the specified rule-sets to the firewall. + Unmatched traffic will bypass the sing-box routes. + + Conflict with `route.default_mark` and `[dialOptions].routing_mark`. + +=== "Without `auto_redirect` enabled" + + !!! question "Since sing-box 1.11.0" + + Add the destination IP CIDR rules in the specified rule-sets to routes, equivalent to adding to `route_address`. + Unmatched traffic will bypass the sing-box routes. + + Note that it **doesn't work on the Android graphical client** due to + the Android VpnService not being able to handle a large number of routes (DeadSystemException), + but otherwise it works fine on all command line clients and Apple platforms. + +#### route_exclude_address_set + +=== "With `auto_redirect` enabled" + + !!! question "Since sing-box 1.10.0" + + !!! quote "" + + Only supported on Linux with nftables and requires `auto_route` and `auto_redirect` enabled. + + Add the destination IP CIDR rules in the specified rule-sets to the firewall. + Matched traffic will bypass the sing-box routes. + +=== "Without `auto_redirect` enabled" + + !!! question "Since sing-box 1.11.0" + + Add the destination IP CIDR rules in the specified rule-sets to routes, equivalent to adding to `route_exclude_address`. + Matched traffic will bypass the sing-box routes. + + Note that it **doesn't work on the Android graphical client** due to + the Android VpnService not being able to handle a large number of routes (DeadSystemException), + but otherwise it works fine on all command line clients and Apple platforms. + +#### endpoint_independent_nat + +!!! info "" + + This item is only available on the gvisor stack, other stacks are endpoint-independent NAT by default. + +Enable endpoint-independent NAT. + +Performance may degrade slightly, so it is not recommended to enable on when it is not needed. + +#### udp_timeout + +UDP NAT expiration time. + +`5m` will be used by default. + +#### stack + +!!! quote "Changes in sing-box 1.8.0" + + :material-delete-alert: The legacy LWIP stack has been deprecated and removed. + +TCP/IP stack. + +| Stack | Description | +|----------|-------------------------------------------------------------------------------------------------------| +| `system` | Perform L3 to L4 translation using the system network stack | +| `gvisor` | Perform L3 to L4 translation using [gVisor](https://github.com/google/gvisor)'s virtual network stack | +| `mixed` | Mixed `system` TCP stack and `gvisor` UDP stack | + +Defaults to the `mixed` stack if the gVisor build tag is enabled, otherwise defaults to the `system` stack. + +#### include_interface + +!!! quote "" + + Interface rules are only supported on Linux and require auto_route. + +Limit interfaces in route. Not limited by default. + +Conflict with `exclude_interface`. + +#### exclude_interface + +!!! warning "" + + When `strict_route` enabled, return traffic to excluded interfaces will not be automatically excluded, so add them as well (example: `br-lan` and `pppoe-wan`). + +Exclude interfaces in route. + +Conflict with `include_interface`. + +#### include_uid + +!!! quote "" + + UID rules are only supported on Linux and require auto_route. + +Limit users in route. Not limited by default. + +#### include_uid_range + +Limit users in route, but in range. + +#### exclude_uid + +Exclude users in route. + +#### exclude_uid_range + +Exclude users in route, but in range. + +#### include_android_user + +!!! quote "" + + Android user and package rules are only supported on Android and require auto_route. + +Limit android users in route. + +| Common user | ID | +|--------------|----| +| Main | 0 | +| Work Profile | 10 | + +#### include_package + +Limit android packages in route. + +#### exclude_package + +Exclude android packages in route. + +#### include_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +Limit MAC addresses in route. Not limited by default. + +Conflict with `exclude_mac_address`. + +#### exclude_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +Exclude MAC addresses in route. + +Conflict with `include_mac_address`. + +#### platform + +Platform-specific settings, provided by client applications. + +#### platform.http_proxy + +System HTTP proxy settings. + +#### platform.http_proxy.enabled + +Enable system HTTP proxy. + +#### platform.http_proxy.server + +==Required== + +HTTP proxy server address. + +#### platform.http_proxy.server_port + +==Required== + +HTTP proxy server port. + +#### platform.http_proxy.bypass_domain + +!!! note "" + + On Apple platforms, `bypass_domain` items matches hostname **suffixes**. + +Hostnames that bypass the HTTP proxy. + +#### platform.http_proxy.match_domain + +!!! quote "" + + Only supported in graphical clients on Apple platforms. + +Hostnames that use the HTTP proxy. + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md new file mode 100644 index 00000000..a41e5ae9 --- /dev/null +++ b/docs/configuration/inbound/tun.zh.md @@ -0,0 +1,623 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + +!!! quote "sing-box 1.13.3 中的更改" + + :material-alert: [strict_route](#strict_route) + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) + :material-plus: [auto_redirect_nfqueue](#auto_redirect_nfqueue) + :material-plus: [exclude_mptcp](#exclude_mptcp) + :material-plus: [auto_redirect_iproute2_fallback_rule_index](#auto_redirect_iproute2_fallback_rule_index) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [loopback_address](#loopback_address) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-delete-alert: [gso](#gso) + :material-alert-decagram: [route_address_set](#stack) + :material-alert-decagram: [route_exclude_address_set](#stack) + +!!! quote "sing-box 1.10.0 中的更改" + + :material-plus: [address](#address) + :material-delete-clock: [inet4_address](#inet4_address) + :material-delete-clock: [inet6_address](#inet6_address) + :material-plus: [route_address](#route_address) + :material-delete-clock: [inet4_route_address](#inet4_route_address) + :material-delete-clock: [inet6_route_address](#inet6_route_address) + :material-plus: [route_exclude_address](#route_address) + :material-delete-clock: [inet4_route_exclude_address](#inet4_route_exclude_address) + :material-delete-clock: [inet6_route_exclude_address](#inet6_route_exclude_address) + :material-plus: [iproute2_table_index](#iproute2_table_index) + :material-plus: [iproute2_rule_index](#iproute2_table_index) + :material-plus: [auto_redirect](#auto_redirect) + :material-plus: [auto_redirect_input_mark](#auto_redirect_input_mark) + :material-plus: [auto_redirect_output_mark](#auto_redirect_output_mark) + :material-plus: [route_address_set](#route_address_set) + :material-plus: [route_exclude_address_set](#route_address_set) + +!!! quote "sing-box 1.9.0 中的更改" + + :material-plus: [platform.http_proxy.bypass_domain](#platformhttp_proxybypass_domain) + :material-plus: [platform.http_proxy.match_domain](#platformhttp_proxymatch_domain) + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [gso](#gso) + :material-alert-decagram: [stack](#stack) + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +### 结构 + +```json +{ + "type": "tun", + "tag": "tun-in", + "interface_name": "tun0", + "address": [ + "172.18.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "mtu": 9000, + "auto_route": true, + "iproute2_table_index": 2022, + "iproute2_rule_index": 9000, + "auto_redirect": true, + "auto_redirect_input_mark": "0x2023", + "auto_redirect_output_mark": "0x2024", + "auto_redirect_reset_mark": "0x2025", + "auto_redirect_nfqueue": 100, + "auto_redirect_iproute2_fallback_rule_index": 32768, + "exclude_mptcp": false, + "loopback_address": [ + "10.7.0.1" + ], + "strict_route": true, + "route_address": [ + "0.0.0.0/1", + "128.0.0.0/1", + "::/1", + "8000::/1" + ], + + "route_exclude_address": [ + "192.168.0.0/16", + "fc00::/7" + ], + "route_address_set": [ + "geoip-cloudflare" + ], + "route_exclude_address_set": [ + "geoip-cn" + ], + "endpoint_independent_nat": false, + "udp_timeout": "5m", + "stack": "system", + "include_interface": [ + "lan0" + ], + "exclude_interface": [ + "lan1" + ], + "include_uid": [ + 0 + ], + "include_uid_range": [ + "1000:99999" + ], + "exclude_uid": [ + 1000 + ], + "exclude_uid_range": [ + "1000:99999" + ], + "include_android_user": [ + 0, + 10 + ], + "include_package": [ + "com.android.chrome" + ], + "exclude_package": [ + "com.android.captiveportallogin" + ], + "include_mac_address": [ + "00:11:22:33:44:55" + ], + "exclude_mac_address": [ + "66:77:88:99:aa:bb" + ], + "platform": { + "http_proxy": { + "enabled": false, + "server": "127.0.0.1", + "server_port": 8080, + "bypass_domain": [], + "match_domain": [] + } + }, + + // 已弃用 + "gso": false, + "inet4_address": [ + "172.19.0.1/30" + ], + "inet6_address": [ + "fdfe:dcba:9876::1/126" + ], + "inet4_route_address": [ + "0.0.0.0/1", + "128.0.0.0/1" + ], + "inet6_route_address": [ + "::/1", + "8000::/1" + ], + "inet4_route_exclude_address": [ + "192.168.0.0/16" + ], + "inet6_route_exclude_address": [ + "fc00::/7" + ], + + ... // 监听字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +!!! warning "" + + 如果 tun 在非特权模式下运行,地址和 MTU 将不会自动配置,请确保设置正确。 + +### Tun 字段 + +#### interface_name + +虚拟设备名称,默认自动选择。 + +#### address + +!!! question "自 sing-box 1.10.0 起" + +==必填== + +tun 接口的 IPv4 和 IPv6 前缀。 + +#### inet4_address + +!!! failure "已在 sing-box 1.10.0 废弃" + + `inet4_address` 已合并到 `address` 且将在 sing-box 1.12.0 中被移除。 + +==必填== + +tun 接口的 IPv4 前缀。 + +#### inet6_address + +!!! failure "已在 sing-box 1.10.0 废弃" + + `inet6_address` 已合并到 `address` 且将在 sing-box 1.12.0 中被移除。 + +tun 接口的 IPv6 前缀。 + +#### mtu + +最大传输单元。 + +#### gso + +!!! failure "已在 sing-box 1.11.0 废弃" + + GSO 对于透明代理场景没有优势,已废弃和不再生效,且将在 sing-box 1.12.0 中被移除。 + +!!! question "自 sing-box 1.8.0 起" + +!!! quote "" + + 仅支持 Linux。 + +启用通用分段卸载。 + +#### auto_route + +设置到 Tun 的默认路由。 + +!!! quote "" + + 为避免流量环回,请设置 `route.auto_detect_interface` 或 `route.default_interface` 或 `outbound.bind_interface`。 + +!!! note "与 Android VPN 一起使用" + + VPN 默认优先于 tun。要使 tun 经过 VPN,启用 `route.override_android_vpn`。 + +!!! note "也启用 `auto_redirect`" + + 在 Linux 上始终推荐使用 `auto_redirect`,它提供更好的路由, 更高的性能(优于 tproxy), 并避免 TUN 与 Docker 桥接网络冲突。 + +#### iproute2_table_index + +!!! question "自 sing-box 1.10.0 起" + +`auto_route` 生成的 iproute2 路由表索引。 + +默认使用 `2022`。 + +#### iproute2_rule_index + +!!! question "自 sing-box 1.10.0 起" + +`auto_route` 生成的 iproute2 规则起始索引。 + +默认使用 `9000`。 + +#### auto_redirect + +!!! question "自 sing-box 1.10.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 已启用。 + +通过使用 nftables 改善 TUN 路由和性能。 + +在 Linux 上始终推荐使用 `auto_redirect`,它提供更好的路由、更高的性能(优于 tproxy),并避免了 TUN 和 Docker 桥接网络之间的冲突。 + +请注意,`auto_redirect` 也适用于 Android,但由于缺少 `nftables` 和 `ip6tables`,仅执行简单的 IPv4 TCP 转发。 +若要在 Android 上通过热点或中继器共享 VPN 连接,请使用 [VPNHotspot](https://github.com/Mygod/VPNHotspot)。 + +`auto_redirect` 还会自动将兼容性规则插入 OpenWrt 的 fw4 表中,即无需额外配置即可在路由器上工作。 + +与 `route.default_mark` 和 `[dialOptions].routing_mark` 冲突。 + +#### auto_redirect_input_mark + +!!! question "自 sing-box 1.10.0 起" + +`auto_redirect` 使用的连接输入标记。 + +默认使用 `0x2023`。 + +#### auto_redirect_output_mark + +!!! question "自 sing-box 1.10.0 起" + +`auto_redirect` 使用的连接输出标记。 + +默认使用 `0x2024`。 + +#### auto_redirect_reset_mark + +!!! question "自 sing-box 1.13.0 起" + +`auto_redirect` 预匹配使用的连接重置标记。 + +默认使用 `0x2025`。 + +#### auto_redirect_nfqueue + +!!! question "自 sing-box 1.13.0 起" + +`auto_redirect` 预匹配使用的 NFQueue 编号。 + +默认使用 `100`。 + +#### auto_redirect_iproute2_fallback_rule_index + +!!! question "自 sing-box 1.12.18 起" + +`auto_redirect` 生成的 iproute2 回退规则索引。 + +此规则在系统默认规则(32766: main,32767: default)之后检查, +仅当系统路由表中未找到路由时才将流量路由到 sing-box 路由表。 + +默认使用 `32768`。 + +#### exclude_mptcp + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 nftables,`auto_route` 和 `auto_redirect` 已启用。 + +由于协议限制,MPTCP 无法被透明代理。 + +此类流量通常由 Apple 系统创建。 + +启用时,MPTCP 连接将绕过 sing-box 直接连接,否则,将被拒绝以避免错误。 + +#### loopback_address + +!!! question "自 sing-box 1.12.0 起" + +环回地址是用于使指向指定地址的 TCP 连接连接到来源地址的。 + +将选项值设置为 `10.7.0.1` 可实现与 SideStore/StosVPN 相同的行为。 + +当启用 `auto_redirect` 时,可以作为网关为局域网设备(而不仅仅是本地)实现相同的行为。 + +#### strict_route + +当启用 `auto_route` 时,强制执行严格的路由规则: + +*在 Linux 中*: + +* 使不支持的网络不可达。 +* 出于历史遗留原因,当未启用 `strict_route` 或 `auto_redirect` 时,所有 ICMP 流量将不会通过 TUN。 +* 当启用 `auto_redirect` 时,`strict_route` 也影响 `SO_BINDTODEVICE` 流量: + * 启用:`SO_BINDTODEVICE` 流量被重定向通过 sing-box。 + * 禁用:`SO_BINDTODEVICE` 流量绕过 sing-box。 + +*在 Windows 中*: + +* 使不支持的网络不可达。 +* 阻止 Windows 的 [普通多宿主 DNS 解析行为](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/dd197552%28v%3Dws.10%29) 造成的 DNS 泄露 + +它可能会使某些 Windows 应用程序(如 VirtualBox)在某些情况下无法正常工作。 + +#### route_address + +!!! question "自 sing-box 1.10.0 起" + +设置到 Tun 的自定义路由。 + +#### inet4_route_address + +!!! failure "已在 sing-box 1.10.0 废弃" + + `inet4_route_address` 已合并到 `route_address` 且将在 sing-box 1.12.0 中被移除。 + +启用 `auto_route` 时使用自定义路由而不是默认路由。 + +#### inet6_route_address + +!!! failure "已在 sing-box 1.10.0 废弃" + + `inet6_route_address` 已合并到 `route_address` 且将在 sing-box 1.12.0 中被移除。 + +启用 `auto_route` 时使用自定义路由而不是默认路由。 + +#### route_exclude_address + +!!! question "自 sing-box 1.10.0 起" + +设置到 Tun 的排除自定义路由。 + +#### inet4_route_exclude_address + +!!! failure "已在 sing-box 1.10.0 废弃" + + `inet4_route_exclude_address` 已合并到 `route_exclude_address` 且将在 sing-box 1.12.0 中被移除。 + +启用 `auto_route` 时排除自定义路由。 + +#### inet6_route_exclude_address + +!!! failure "已在 sing-box 1.10.0 废弃" + + `inet6_route_exclude_address` 已合并到 `route_exclude_address` 且将在 sing-box 1.12.0 中被移除。 + +启用 `auto_route` 时排除自定义路由。 + +#### route_address_set + +=== "`auto_redirect` 已启用" + + !!! question "自 sing-box 1.10.0 起" + + !!! quote "" + + 仅支持 Linux,且需要 nftables,`auto_route` 和 `auto_redirect` 已启用。 + + 将指定规则集中的目标 IP CIDR 规则添加到防火墙。 + 不匹配的流量将绕过 sing-box 路由。 + +=== "`auto_redirect` 未启用" + + !!! question "自 sing-box 1.11.0 起" + + 将指定规则集中的目标 IP CIDR 规则添加到路由,相当于添加到 `route_address`。 + 不匹配的流量将绕过 sing-box 路由。 + + 请注意,由于 Android VpnService 无法处理大量路由(DeadSystemException), + 因此它**在 Android 图形客户端上不起作用**,但除此之外,它在所有命令行客户端和 Apple 平台上都可以正常工作。 + +#### route_exclude_address_set + +=== "`auto_redirect` 已启用" + + !!! question "自 sing-box 1.10.0 起" + + !!! quote "" + + 仅支持 Linux,且需要 nftables,`auto_route` 和 `auto_redirect` 已启用。 + + 将指定规则集中的目标 IP CIDR 规则添加到防火墙。 + 匹配的流量将绕过 sing-box 路由。 + + 与 `route.default_mark` 和 `[dialOptions].routing_mark` 冲突。 + +=== "`auto_redirect` 未启用" + + !!! question "自 sing-box 1.11.0 起" + + 将指定规则集中的目标 IP CIDR 规则添加到路由,相当于添加到 `route_exclude_address`。 + 匹配的流量将绕过 sing-box 路由。 + + 请注意,由于 Android VpnService 无法处理大量路由(DeadSystemException), + 因此它**在 Android 图形客户端上不起作用**,但除此之外,它在所有命令行客户端和 Apple 平台上都可以正常工作。 + +#### endpoint_independent_nat + +启用独立于端点的 NAT。 + +性能可能会略有下降,所以不建议在不需要的时候开启。 + +#### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +#### stack + +!!! quote "sing-box 1.8.0 中的更改" + + :material-delete-alert: 旧的 LWIP 栈已被弃用并移除。 + +TCP/IP 栈。 + +| 栈 | 描述 | +|----------|-------------------------------------------------------------------------------------------------------| +| `system` | 基于系统网络栈执行 L3 到 L4 转换 | +| `gvisor` | 基于 [gVisor](https://github.com/google/gvisor) 虚拟网络栈执行 L3 到 L4 转换 | +| `mixed` | 混合 `system` TCP 栈与 `gvisor` UDP 栈 | + +默认使用 `mixed` 栈如果 gVisor 构建标记已启用,否则默认使用 `system` 栈。 + +#### include_interface + +!!! quote "" + + 接口规则仅在 Linux 下被支持,并且需要 `auto_route`。 + +限制被路由的接口。默认不限制。 + +与 `exclude_interface` 冲突。 + +#### exclude_interface + +!!! warning "" + + 当 `strict_route` 启用,到被排除接口的回程流量将不会被自动排除,因此也要添加它们(例:`br-lan` 与 `pppoe-wan`)。 + +排除路由的接口。 + +与 `include_interface` 冲突。 + +#### include_uid + +!!! quote "" + + UID 规则仅在 Linux 下被支持,并且需要 `auto_route`。 + +限制被路由的用户。默认不限制。 + +#### include_uid_range + +限制被路由的用户范围。 + +#### exclude_uid + +排除路由的用户。 + +#### exclude_uid_range + +排除路由的用户范围。 + +#### include_android_user + +!!! quote "" + + Android 用户和应用规则仅在 Android 下被支持,并且需要 `auto_route`。 + +限制被路由的 Android 用户。 + +| 常用用户 | ID | +|------|----| +| 您 | 0 | +| 工作资料 | 10 | + +#### include_package + +限制被路由的 Android 应用包名。 + +#### exclude_package + +排除路由的 Android 应用包名。 + +#### include_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 和 `auto_redirect` 已启用。 + +限制被路由的 MAC 地址。默认不限制。 + +与 `exclude_mac_address` 冲突。 + +#### exclude_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 和 `auto_redirect` 已启用。 + +排除路由的 MAC 地址。 + +与 `include_mac_address` 冲突。 + +#### platform + +平台特定的设置,由客户端应用提供。 + +#### platform.http_proxy + +系统 HTTP 代理设置。 + +##### platform.http_proxy.enabled + +启用系统 HTTP 代理。 + +##### platform.http_proxy.server + +==必填== + +系统 HTTP 代理服务器地址。 + +##### platform.http_proxy.server_port + +==必填== + +系统 HTTP 代理服务器端口。 + +##### platform.http_proxy.bypass_domain + +!!! note "" + + 在 Apple 平台,`bypass_domain` 项匹配主机名 **后缀**. + +绕过代理的主机名列表。 + +##### platform.http_proxy.match_domain + +!!! quote "" + + 仅在 Apple 平台图形客户端中支持。 + +代理的主机名列表。 + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 diff --git a/docs/configuration/inbound/vless.md b/docs/configuration/inbound/vless.md new file mode 100644 index 00000000..93faf716 --- /dev/null +++ b/docs/configuration/inbound/vless.md @@ -0,0 +1,59 @@ +### Structure + +```json +{ + "type": "vless", + "tag": "vless-in", + + ... // Listen Fields + + "users": [ + { + "name": "sekai", + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "flow": "" + } + ], + "tls": {}, + "multiplex": {}, + "transport": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +==Required== + +VLESS users. + +#### users.uuid + +==Required== + +VLESS user id. + +#### users.flow + +VLESS Sub-protocol. + +Available values: + +* `xtls-rprx-vision` + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#inbound) for details. + +#### transport + +V2Ray Transport configuration, see [V2Ray Transport](/configuration/shared/v2ray-transport/). diff --git a/docs/configuration/inbound/vless.zh.md b/docs/configuration/inbound/vless.zh.md new file mode 100644 index 00000000..2ce4785b --- /dev/null +++ b/docs/configuration/inbound/vless.zh.md @@ -0,0 +1,59 @@ +### 结构 + +```json +{ + "type": "vless", + "tag": "vless-in", + + ... // 监听字段 + + "users": [ + { + "name": "sekai", + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "flow": "" + } + ], + "tls": {}, + "multiplex": {}, + "transport": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +==必填== + +VLESS 用户。 + +#### users.uuid + +==必填== + +VLESS 用户 ID。 + +#### users.flow + +VLESS 子协议。 + +可用值: + +* `xtls-rprx-vision` + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 + +#### transport + +V2Ray 传输配置,参阅 [V2Ray 传输层](/zh/configuration/shared/v2ray-transport/)。 diff --git a/docs/configuration/inbound/vmess.md b/docs/configuration/inbound/vmess.md new file mode 100644 index 00000000..f38a6cae --- /dev/null +++ b/docs/configuration/inbound/vmess.md @@ -0,0 +1,54 @@ +### Structure + +```json +{ + "type": "vmess", + "tag": "vmess-in", + + ... // Listen Fields + + "users": [ + { + "name": "sekai", + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "alterId": 0 + } + ], + "tls": {}, + "multiplex": {}, + "transport": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +==Required== + +VMess users. + +| Alter ID | Description | +|----------|-------------------------| +| 0 | Disable legacy protocol | +| > 0 | Enable legacy protocol | + +!!! warning "" + + Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#inbound) for details. + +#### transport + +V2Ray Transport configuration, see [V2Ray Transport](/configuration/shared/v2ray-transport/). diff --git a/docs/configuration/inbound/vmess.zh.md b/docs/configuration/inbound/vmess.zh.md new file mode 100644 index 00000000..f741ed1b --- /dev/null +++ b/docs/configuration/inbound/vmess.zh.md @@ -0,0 +1,54 @@ +### 结构 + +```json +{ + "type": "vmess", + "tag": "vmess-in", + + ... // 监听字段 + + "users": [ + { + "name": "sekai", + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "alterId": 0 + } + ], + "tls": {}, + "multiplex": {}, + "transport": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +==必填== + +VMess 用户。 + +| Alter ID | 描述 | +|----------|-------| +| 0 | 禁用旧协议 | +| > 0 | 启用旧协议 | + +!!! warning "" + + 提供旧协议支持(VMess MD5 身份验证)仅出于兼容性目的,不建议使用 alterId > 1。 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 + +#### transport + +V2Ray 传输配置,参阅 [V2Ray 传输层](/zh/configuration/shared/v2ray-transport/)。 diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 00000000..81cb8f38 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,54 @@ +# Introduction + +sing-box uses JSON for configuration files. +### Structure + +```json +{ + "log": {}, + "dns": {}, + "ntp": {}, + "certificate": {}, + "certificate_providers": [], + "endpoints": [], + "inbounds": [], + "outbounds": [], + "route": {}, + "services": [], + "experimental": {} +} +``` + +### Fields + +| Key | Format | +|----------------|---------------------------------| +| `log` | [Log](./log/) | +| `dns` | [DNS](./dns/) | +| `ntp` | [NTP](./ntp/) | +| `certificate` | [Certificate](./certificate/) | +| `certificate_providers` | [Certificate Provider](./shared/certificate-provider/) | +| `endpoints` | [Endpoint](./endpoint/) | +| `inbounds` | [Inbound](./inbound/) | +| `outbounds` | [Outbound](./outbound/) | +| `route` | [Route](./route/) | +| `services` | [Service](./service/) | +| `experimental` | [Experimental](./experimental/) | + +### Check + +```bash +sing-box check +``` + +### Format + +```bash +sing-box format -w -c config.json -D config_directory +``` + +### Merge + +```bash +sing-box merge output.json -c config.json -D config_directory +``` diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md new file mode 100644 index 00000000..350db5d4 --- /dev/null +++ b/docs/configuration/index.zh.md @@ -0,0 +1,54 @@ +# 引言 + +sing-box 使用 JSON 作为配置文件格式。 +### 结构 + +```json +{ + "log": {}, + "dns": {}, + "ntp": {}, + "certificate": {}, + "certificate_providers": [], + "endpoints": [], + "inbounds": [], + "outbounds": [], + "route": {}, + "services": [], + "experimental": {} +} +``` + +### 字段 + +| Key | Format | +|----------------|------------------------| +| `log` | [日志](./log/) | +| `dns` | [DNS](./dns/) | +| `ntp` | [NTP](./ntp/) | +| `certificate` | [证书](./certificate/) | +| `certificate_providers` | [证书提供者](./shared/certificate-provider/) | +| `endpoints` | [端点](./endpoint/) | +| `inbounds` | [入站](./inbound/) | +| `outbounds` | [出站](./outbound/) | +| `route` | [路由](./route/) | +| `services` | [服务](./service/) | +| `experimental` | [实验性](./experimental/) | + +### 检查 + +```bash +sing-box check +``` + +### 格式化 + +```bash +sing-box format -w -c config.json -D config_directory +``` + +### 合并 + +```bash +sing-box merge output.json -c config.json -D config_directory +``` diff --git a/docs/configuration/log/index.md b/docs/configuration/log/index.md new file mode 100644 index 00000000..ff45e165 --- /dev/null +++ b/docs/configuration/log/index.md @@ -0,0 +1,33 @@ +# Log + +### Structure + +```json +{ + "log": { + "disabled": false, + "level": "info", + "output": "box.log", + "timestamp": true + } +} + +``` + +### Fields + +#### disabled + +Disable logging, no output after start. + +#### level + +Log level. One of: `trace` `debug` `info` `warn` `error` `fatal` `panic`. + +#### output + +Output file path. Will not write log to console after enable. + +#### timestamp + +Add time to each line. \ No newline at end of file diff --git a/docs/configuration/log/index.zh.md b/docs/configuration/log/index.zh.md new file mode 100644 index 00000000..faaa82e7 --- /dev/null +++ b/docs/configuration/log/index.zh.md @@ -0,0 +1,33 @@ +# 日志 + +### 结构 + +```json +{ + "log": { + "disabled": false, + "level": "info", + "output": "box.log", + "timestamp": true + } +} + +``` + +### 字段 + +#### disabled + +禁用日志,启动后不输出日志。 + +#### level + +日志等级,可选值:`trace` `debug` `info` `warn` `error` `fatal` `panic`。 + +#### output + +输出文件路径,启动后将不输出到控制台。 + +#### timestamp + +添加时间到每行。 \ No newline at end of file diff --git a/docs/configuration/ntp/index.md b/docs/configuration/ntp/index.md new file mode 100644 index 00000000..b95b9b1f --- /dev/null +++ b/docs/configuration/ntp/index.md @@ -0,0 +1,50 @@ +# NTP + +Built-in NTP client service. + +If enabled, it will provide time for protocols like TLS/Shadowsocks/VMess, which is useful for environments where time +synchronization is not possible. + +### Structure + +```json +{ + "ntp": { + "enabled": false, + "server": "time.apple.com", + "server_port": 123, + "interval": "30m", + + ... // Dial Fields + } +} + +``` + +### Fields + +#### enabled + +Enable NTP service. + +#### server + +==Required== + +NTP server address. + +#### server_port + +NTP server port. + +123 is used by default. + +#### interval + +Time synchronization interval. + +30 minutes is used by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. \ No newline at end of file diff --git a/docs/configuration/ntp/index.zh.md b/docs/configuration/ntp/index.zh.md new file mode 100644 index 00000000..cab1eb67 --- /dev/null +++ b/docs/configuration/ntp/index.zh.md @@ -0,0 +1,49 @@ +# NTP + +内建的 NTP 客户端服务。 + +如果启用,它将为像 TLS/Shadowsocks/VMess 这样的协议提供时间,这对于无法进行时间同步的环境很有用。 + +### 结构 + +```json +{ + "ntp": { + "enabled": false, + "server": "time.apple.com", + "server_port": 123, + "interval": "30m", + + ... // 拨号字段 + } +} + +``` + +### 字段 + +#### enabled + +启用 NTP 服务。 + +#### server + +==必填== + +NTP 服务器地址。 + +#### server_port + +NTP 服务器端口。 + +默认使用 123。 + +#### interval + +时间同步间隔。 + +默认使用 30 分钟。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/anytls.md b/docs/configuration/outbound/anytls.md new file mode 100644 index 00000000..4b3e0371 --- /dev/null +++ b/docs/configuration/outbound/anytls.md @@ -0,0 +1,66 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +### Structure + +```json +{ + "type": "anytls", + "tag": "anytls-out", + + "server": "127.0.0.1", + "server_port": 1080, + "password": "8JCsPssfgS8tiRwiMlhARg==", + "idle_session_check_interval": "30s", + "idle_session_timeout": "30s", + "min_idle_session": 5, + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### password + +==Required== + +The AnyTLS password. + +#### idle_session_check_interval + +Interval checking for idle sessions. Default: 30s. + +#### idle_session_timeout + +In the check, close sessions that have been idle for longer than this. Default: 30s. + +#### min_idle_session + +In the check, at least the first `n` idle sessions are kept open. Default value: `n`=0 + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/anytls.zh.md b/docs/configuration/outbound/anytls.zh.md new file mode 100644 index 00000000..c1f8999e --- /dev/null +++ b/docs/configuration/outbound/anytls.zh.md @@ -0,0 +1,66 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +### 结构 + +```json +{ + "type": "anytls", + "tag": "anytls-out", + + "server": "127.0.0.1", + "server_port": 1080, + "password": "8JCsPssfgS8tiRwiMlhARg==", + "idle_session_check_interval": "30s", + "idle_session_timeout": "30s", + "min_idle_session": 5, + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### password + +==必填== + +AnyTLS 密码。 + +#### idle_session_check_interval + +检查空闲会话的时间间隔。默认值:30秒。 + +#### idle_session_timeout + +在检查中,关闭闲置时间超过此值的会话。默认值:30秒。 + +#### min_idle_session + +在检查中,至少前 `n` 个空闲会话保持打开状态。默认值:`n`=0 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/block.md b/docs/configuration/outbound/block.md new file mode 100644 index 00000000..6e232263 --- /dev/null +++ b/docs/configuration/outbound/block.md @@ -0,0 +1,16 @@ +--- +icon: material/delete-clock +--- + +### Structure + +```json +{ + "type": "block", + "tag": "block" +} +``` + +### Fields + +No fields. diff --git a/docs/configuration/outbound/block.zh.md b/docs/configuration/outbound/block.zh.md new file mode 100644 index 00000000..93227476 --- /dev/null +++ b/docs/configuration/outbound/block.zh.md @@ -0,0 +1,18 @@ +--- +icon: material/delete-clock +--- + +`block` 出站关闭所有传入请求。 + +### 结构 + +```json +{ + "type": "block", + "tag": "block" +} +``` + +### 字段 + +无字段。 diff --git a/docs/configuration/outbound/direct.md b/docs/configuration/outbound/direct.md new file mode 100644 index 00000000..3e28db8f --- /dev/null +++ b/docs/configuration/outbound/direct.md @@ -0,0 +1,48 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.11.0" + + :material-delete-clock: [override_address](#override_address) + :material-delete-clock: [override_port](#override_port) + +`direct` outbound send requests directly. + +### Structure + +```json +{ + "type": "direct", + "tag": "direct-out", + + "override_address": "1.0.0.1", + "override_port": 53, + + ... // Dial Fields +} +``` + +### Fields + +#### override_address + +!!! failure "Deprecated in sing-box 1.11.0" + + Destination override fields are deprecated in sing-box 1.11.0 and will be removed in sing-box 1.13.0, see [Migration](/migration/#migrate-destination-override-fields-to-route-options). + +Override the connection destination address. + +#### override_port + +!!! failure "Deprecated in sing-box 1.11.0" + + Destination override fields are deprecated in sing-box 1.11.0 and will be removed in sing-box 1.13.0, see [Migration](/migration/#migrate-destination-override-fields-to-route-options). + +Override the connection destination port. + +Protocol value can be `1` or `2`. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/direct.zh.md b/docs/configuration/outbound/direct.zh.md new file mode 100644 index 00000000..824a3529 --- /dev/null +++ b/docs/configuration/outbound/direct.zh.md @@ -0,0 +1,46 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.11.0 中的更改" + + :material-delete-clock: [override_address](#override_address) + :material-delete-clock: [override_port](#override_port) + +`direct` 出站直接发送请求。 + +### 结构 + +```json +{ + "type": "direct", + "tag": "direct-out", + + "override_address": "1.0.0.1", + "override_port": 53, + + ... // 拨号字段 +} +``` + +### 字段 + +#### override_address + +!!! failure "已在 sing-box 1.11.0 废弃" + + 目标覆盖字段在 sing-box 1.11.0 中已废弃,并将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 + +覆盖连接目标地址。 + +#### override_port + +!!! failure "已在 sing-box 1.11.0 废弃" + + 目标覆盖字段在 sing-box 1.11.0 中已废弃,并将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 + +覆盖连接目标端口。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/dns.md b/docs/configuration/outbound/dns.md new file mode 100644 index 00000000..d7336041 --- /dev/null +++ b/docs/configuration/outbound/dns.md @@ -0,0 +1,26 @@ +--- +icon: material/delete-clock +--- + +!!! failure "Deprecated in sing-box 1.11.0" + + Legacy special outbounds are deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-legacy-special-outbounds-to-rule-actions). + +`dns` outbound is a internal DNS server. + +### Structure + +```json +{ + "type": "dns", + "tag": "dns-out" +} +``` + +!!! note "" + + There are no outbound connections by the DNS outbound, all requests are handled internally. + +### Fields + +No fields. \ No newline at end of file diff --git a/docs/configuration/outbound/dns.zh.md b/docs/configuration/outbound/dns.zh.md new file mode 100644 index 00000000..592075b3 --- /dev/null +++ b/docs/configuration/outbound/dns.zh.md @@ -0,0 +1,26 @@ +--- +icon: material/delete-clock +--- + +!!! failure "已在 sing-box 1.11.0 废弃" + + 旧的特殊出站已被弃用,且将在 sing-box 1.13.0 中被移除, 参阅 [迁移指南](/zh/migration/#迁移旧的特殊出站到规则动作). + +`dns` 出站是一个内部 DNS 服务器。 + +### 结构 + +```json +{ + "type": "dns", + "tag": "dns-out" +} +``` + +!!! note "" + + DNS 出站没有出站连接,所有请求均在内部处理。 + +### 字段 + +无字段。 \ No newline at end of file diff --git a/docs/configuration/outbound/http.md b/docs/configuration/outbound/http.md new file mode 100644 index 00000000..0b9dfa23 --- /dev/null +++ b/docs/configuration/outbound/http.md @@ -0,0 +1,58 @@ +`http` outbound is a HTTP CONNECT proxy client. + +### Structure + +```json +{ + "type": "http", + "tag": "http-out", + + "server": "127.0.0.1", + "server_port": 1080, + "username": "sekai", + "password": "admin", + "path": "", + "headers": {}, + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### username + +Basic authorization username. + +#### password + +Basic authorization password. + +#### path + +Path of HTTP request. + +#### headers + +Extra headers of HTTP request. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/http.zh.md b/docs/configuration/outbound/http.zh.md new file mode 100644 index 00000000..55387a63 --- /dev/null +++ b/docs/configuration/outbound/http.zh.md @@ -0,0 +1,58 @@ +`http` 出站是一个 HTTP CONNECT 代理客户端 + +### 结构 + +```json +{ + "type": "http", + "tag": "http-out", + + "server": "127.0.0.1", + "server_port": 1080, + "username": "sekai", + "password": "admin", + "path": "", + "headers": {}, + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### username + +Basic 认证用户名。 + +#### password + +Basic 认证密码。 + +#### path + +HTTP 请求路径。 + +#### headers + +HTTP 请求的额外标头。 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/hysteria.md b/docs/configuration/outbound/hysteria.md new file mode 100644 index 00000000..b326e06d --- /dev/null +++ b/docs/configuration/outbound/hysteria.md @@ -0,0 +1,141 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [server_ports](#server_ports) + :material-plus: [hop_interval](#hop_interval) + +### Structure + +```json +{ + "type": "hysteria", + "tag": "hysteria-out", + + "server": "127.0.0.1", + "server_port": 1080, + "server_ports": [ + "2080:3000" + ], + "hop_interval": "", + "up": "100 Mbps", + "up_mbps": 100, + "down": "100 Mbps", + "down_mbps": 100, + "obfs": "fuck me till the daylight", + "auth": "", + "auth_str": "password", + "recv_window_conn": 0, + "recv_window": 0, + "disable_mtu_discovery": false, + "network": "tcp", + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### server_ports + +!!! question "Since sing-box 1.12.0" + +Server port range list. + +Conflicts with `server_port`. + +#### hop_interval + +!!! question "Since sing-box 1.12.0" + +Port hopping interval. + +`30s` is used by default. + +#### up, down + +==Required== + +Format: `[Integer] [Unit]` e.g. `100 Mbps, 640 KBps, 2 Gbps` + +Supported units (case sensitive, b = bits, B = bytes, 8b=1B): + + bps (bits per second) + Bps (bytes per second) + Kbps (kilobits per second) + KBps (kilobytes per second) + Mbps (megabits per second) + MBps (megabytes per second) + Gbps (gigabits per second) + GBps (gigabytes per second) + Tbps (terabits per second) + TBps (terabytes per second) + +#### up_mbps, down_mbps + +==Required== + +`up, down` in Mbps. + +#### obfs + +Obfuscated password. + +#### auth + +Authentication password, in base64. + +#### auth_str + +Authentication password. + +#### recv_window_conn + +The QUIC stream-level flow control window for receiving data. + +`15728640 (15 MB/s)` will be used if empty. + +#### recv_window + +The QUIC connection-level flow control window for receiving data. + +`67108864 (64 MB/s)` will be used if empty. + +#### disable_mtu_discovery + +Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size. + +Force enabled on for systems other than Linux and Windows (according to upstream). + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/hysteria.zh.md b/docs/configuration/outbound/hysteria.zh.md new file mode 100644 index 00000000..ae1d3590 --- /dev/null +++ b/docs/configuration/outbound/hysteria.zh.md @@ -0,0 +1,142 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [server_ports](#server_ports) + :material-plus: [hop_interval](#hop_interval) + +### 结构 + +```json +{ + "type": "hysteria", + "tag": "hysteria-out", + + "server": "127.0.0.1", + "server_port": 1080, + "server_ports": [ + "2080:3000" + ], + "hop_interval": "", + "up": "100 Mbps", + "up_mbps": 100, + "down": "100 Mbps", + "down_mbps": 100, + "obfs": "fuck me till the daylight", + "auth": "", + "auth_str": "password", + "recv_window_conn": 0, + "recv_window": 0, + "disable_mtu_discovery": false, + "network": "tcp", + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### server_ports + +!!! question "自 sing-box 1.12.0 起" + +服务器端口范围列表。 + +与 `server_port` 冲突。 + +#### hop_interval + +!!! question "自 sing-box 1.12.0 起" + +端口跳跃间隔。 + +默认使用 `30s`。 + +#### up, down + +==必填== + +格式: `[Integer] [Unit]` 例如: `100 Mbps, 640 KBps, 2 Gbps` + +支持的单位 (大小写敏感, b = bits, B = bytes, 8b=1B): + + bps (bits per second) + Bps (bytes per second) + Kbps (kilobits per second) + KBps (kilobytes per second) + Mbps (megabits per second) + MBps (megabytes per second) + Gbps (gigabits per second) + GBps (gigabytes per second) + Tbps (terabits per second) + TBps (terabytes per second) + +#### up_mbps, down_mbps + +==必填== + +以 Mbps 为单位的 `up, down`。 + +#### obfs + +混淆密码。 + +#### auth + +base64 编码的认证密码。 + +#### auth_str + +认证密码。 + +#### recv_window_conn + +用于接收数据的 QUIC 流级流控制窗口。 + +默认 `15728640 (15 MB/s)`。 + +#### recv_window + +用于接收数据的 QUIC 连接级流控制窗口。 + +默认 `67108864 (64 MB/s)`。 + +#### disable_mtu_discovery + +禁用路径 MTU 发现 (RFC 8899)。 数据包的大小最多为 1252 (IPv4) / 1232 (IPv6) 字节。 + +强制为 Linux 和 Windows 以外的系统启用(根据上游)。 + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/hysteria2.md b/docs/configuration/outbound/hysteria2.md new file mode 100644 index 00000000..a71dd1e0 --- /dev/null +++ b/docs/configuration/outbound/hysteria2.md @@ -0,0 +1,141 @@ +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [hop_interval_max](#hop_interval_max) + :material-plus: [bbr_profile](#bbr_profile) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [server_ports](#server_ports) + :material-plus: [hop_interval](#hop_interval) + +### Structure + +```json +{ + "type": "hysteria2", + "tag": "hy2-out", + + "server": "127.0.0.1", + "server_port": 1080, + "server_ports": [ + "2080:3000" + ], + "hop_interval": "", + "hop_interval_max": "", + "up_mbps": 100, + "down_mbps": 100, + "obfs": { + "type": "salamander", + "password": "cry_me_a_r1ver" + }, + "password": "goofy_ahh_password", + "network": "tcp", + "tls": {}, + "bbr_profile": "", + "brutal_debug": false, + + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +!!! warning "Difference from official Hysteria2" + + The official Hysteria2 supports an authentication method called **userpass**, + which essentially uses a combination of `:` as the actual password, + while sing-box does not provide this alias. + If you are planning to use sing-box with the official program, + please note that you will need to fill the combination as the password. + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +Ignored if `server_ports` is set. + +#### server_ports + +!!! question "Since sing-box 1.11.0" + +Server port range list. + +Conflicts with `server_port`. + +#### hop_interval + +!!! question "Since sing-box 1.11.0" + +Port hopping interval. + +`30s` is used by default. + +#### hop_interval_max + +!!! question "Since sing-box 1.14.0" + +Maximum port hopping interval, used for randomization. + +If set, the actual hop interval will be randomly chosen between `hop_interval` and `hop_interval_max`. + +#### up_mbps, down_mbps + +Max bandwidth, in Mbps. + +If empty, the BBR congestion control algorithm will be used instead of Hysteria CC. + +#### obfs.type + +QUIC traffic obfuscator type, only available with `salamander`. + +Disabled if empty. + +#### obfs.password + +QUIC traffic obfuscator password. + +#### password + +Authentication password. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +#### bbr_profile + +!!! question "Since sing-box 1.14.0" + +BBR congestion control algorithm profile, one of `conservative` `standard` `aggressive`. + +`standard` is used by default. + +#### brutal_debug + +Enable debug information logging for Hysteria Brutal CC. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/hysteria2.zh.md b/docs/configuration/outbound/hysteria2.zh.md new file mode 100644 index 00000000..0fb17bbd --- /dev/null +++ b/docs/configuration/outbound/hysteria2.zh.md @@ -0,0 +1,139 @@ +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [hop_interval_max](#hop_interval_max) + :material-plus: [bbr_profile](#bbr_profile) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [server_ports](#server_ports) + :material-plus: [hop_interval](#hop_interval) + +### 结构 + +```json +{ + "type": "hysteria2", + "tag": "hy2-out", + + "server": "127.0.0.1", + "server_port": 1080, + "server_ports": [ + "2080:3000" + ], + "hop_interval": "", + "hop_interval_max": "", + "up_mbps": 100, + "down_mbps": 100, + "obfs": { + "type": "salamander", + "password": "cry_me_a_r1ver" + }, + "password": "goofy_ahh_password", + "network": "tcp", + "tls": {}, + "bbr_profile": "", + "brutal_debug": false, + + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +!!! warning "与官方 Hysteria2 的区别" + + 官方程序支持一种名为 **userpass** 的验证方式, + 本质上是将用户名与密码的组合 `:` 作为实际上的密码,而 sing-box 不提供此别名。 + 要将 sing-box 与官方程序一起使用, 您需要填写该组合作为实际密码。 + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +如果设置了 `server_ports`,则忽略此项。 + +#### server_ports + +!!! question "自 sing-box 1.11.0 起" + +服务器端口范围列表。 + +与 `server_port` 冲突。 + +#### hop_interval + +!!! question "自 sing-box 1.11.0 起" + +端口跳跃间隔。 + +默认使用 `30s`。 + +#### hop_interval_max + +!!! question "自 sing-box 1.14.0 起" + +最大端口跳跃间隔,用于随机化。 + +如果设置,实际跳跃间隔将在 `hop_interval` 和 `hop_interval_max` 之间随机选择。 + +#### up_mbps, down_mbps + +最大带宽。 + +如果为空,将使用 BBR 拥塞控制算法而不是 Hysteria CC。 + +#### obfs.type + +QUIC 流量混淆器类型,仅可设为 `salamander`。 + +如果为空则禁用。 + +#### obfs.password + +QUIC 流量混淆器密码. + +#### password + +认证密码。 + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +#### bbr_profile + +!!! question "自 sing-box 1.14.0 起" + +BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 + +默认使用 `standard`。 + +#### brutal_debug + +启用 Hysteria Brutal CC 的调试信息日志记录。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md new file mode 100644 index 00000000..47b8a96a --- /dev/null +++ b/docs/configuration/outbound/index.md @@ -0,0 +1,49 @@ +# Outbound + +### Structure + +```json +{ + "outbounds": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### Fields + +| Type | Format | +|----------------|--------------------------------| +| `direct` | [Direct](./direct/) | +| `block` | [Block](./block/) | +| `socks` | [SOCKS](./socks/) | +| `http` | [HTTP](./http/) | +| `shadowsocks` | [Shadowsocks](./shadowsocks/) | +| `vmess` | [VMess](./vmess/) | +| `trojan` | [Trojan](./trojan/) | +| `wireguard` | [Wireguard](./wireguard/) | +| `hysteria` | [Hysteria](./hysteria/) | +| `vless` | [VLESS](./vless/) | +| `shadowtls` | [ShadowTLS](./shadowtls/) | +| `tuic` | [TUIC](./tuic/) | +| `hysteria2` | [Hysteria2](./hysteria2/) | +| `anytls` | [AnyTLS](./anytls/) | +| `tor` | [Tor](./tor/) | +| `ssh` | [SSH](./ssh/) | +| `dns` | [DNS](./dns/) | +| `selector` | [Selector](./selector/) | +| `urltest` | [URLTest](./urltest/) | +| `naive` | [NaiveProxy](./naive/) | + +#### tag + +The tag of the outbound. + +### Features + +#### Outbounds that support IP connection + +* `WireGuard` diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md new file mode 100644 index 00000000..a1c4a7ad --- /dev/null +++ b/docs/configuration/outbound/index.zh.md @@ -0,0 +1,49 @@ +# 出站 + +### 结构 + +```json +{ + "outbounds": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### 字段 + +| 类型 | 格式 | +|----------------|--------------------------------| +| `direct` | [Direct](./direct/) | +| `block` | [Block](./block/) | +| `socks` | [SOCKS](./socks/) | +| `http` | [HTTP](./http/) | +| `shadowsocks` | [Shadowsocks](./shadowsocks/) | +| `vmess` | [VMess](./vmess/) | +| `trojan` | [Trojan](./trojan/) | +| `wireguard` | [Wireguard](./wireguard/) | +| `hysteria` | [Hysteria](./hysteria/) | +| `vless` | [VLESS](./vless/) | +| `shadowtls` | [ShadowTLS](./shadowtls/) | +| `tuic` | [TUIC](./tuic/) | +| `hysteria2` | [Hysteria2](./hysteria2/) | +| `anytls` | [AnyTLS](./anytls/) | +| `tor` | [Tor](./tor/) | +| `ssh` | [SSH](./ssh/) | +| `dns` | [DNS](./dns/) | +| `selector` | [Selector](./selector/) | +| `urltest` | [URLTest](./urltest/) | +| `naive` | [NaiveProxy](./naive/) | + +#### tag + +出站的标签。 + +### 特性 + +#### 支持 IP 连接的出站 + +* `WireGuard` diff --git a/docs/configuration/outbound/naive.md b/docs/configuration/outbound/naive.md new file mode 100644 index 00000000..5ed9d2b8 --- /dev/null +++ b/docs/configuration/outbound/naive.md @@ -0,0 +1,116 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.13.0" + +### Structure + +```json +{ + "type": "naive", + "tag": "naive-out", + + "server": "127.0.0.1", + "server_port": 443, + "username": "sekai", + "password": "password", + "insecure_concurrency": 0, + "extra_headers": {}, + "udp_over_tcp": false | {}, + "quic": false, + "quic_congestion_control": "", + "tls": {}, + + ... // Dial Fields +} +``` + +!!! warning "Platform Support" + + NaiveProxy outbound is only available on Apple platforms, Android, Windows and certain Linux builds. + + **Official Release Build Variants:** + + | Build Variant | Platforms | Description | + |---------------|-----------|-------------| + | (no suffix) | Linux amd64/arm64 | purego build, `libcronet.so` included | + | `-glibc` | Linux 386/amd64/arm/arm64/mipsle/mips64le/riscv64/loong64 | CGO build, dynamically linked with glibc, requires glibc >= 2.31 (loong64: >= 2.36) | + | `-musl` | Linux 386/amd64/arm/arm64/mipsle/riscv64/loong64 | CGO build, statically linked with musl | + | (no suffix) | Windows amd64/arm64 | purego build, `libcronet.dll` included | + + For Linux, choose the glibc or musl variant based on your distribution's libc type. + + **Runtime Requirements:** + + - **Linux purego**: `libcronet.so` must be in the same directory as the sing-box binary or in system library path + - **Windows**: `libcronet.dll` must be in the same directory as `sing-box.exe` or in a directory listed in `PATH` + + For self-built binaries, see [Build from source](/installation/build-from-source/#with_naive_outbound). + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### username + +Authentication username. + +#### password + +Authentication password. + +#### insecure_concurrency + +Number of concurrent tunnel connections. Multiple connections make the tunneling easier to detect through traffic analysis, which defeats the purpose of NaiveProxy's design to resist traffic analysis. + +#### extra_headers + +Extra headers to send in HTTP requests. + +#### udp_over_tcp + +UDP over TCP protocol settings. + +See [UDP Over TCP](/configuration/shared/udp-over-tcp/) for details. + +#### quic + +Use QUIC instead of HTTP/2. + +#### quic_congestion_control + +QUIC congestion control algorithm. + +| Algorithm | Description | +|-----------|-------------| +| `bbr` | BBR | +| `bbr2` | BBRv2 | +| `cubic` | CUBIC | +| `reno` | New Reno | + +`bbr` is used by default (the default of QUICHE, used by Chromium which NaiveProxy is based on). + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +Only `server_name`, `certificate`, `certificate_path` and `ech` are supported. + +Self-signed certificates change traffic behavior significantly, which defeats the purpose of NaiveProxy's design to resist traffic analysis, and should not be used in production. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/naive.zh.md b/docs/configuration/outbound/naive.zh.md new file mode 100644 index 00000000..dbfd7fbf --- /dev/null +++ b/docs/configuration/outbound/naive.zh.md @@ -0,0 +1,116 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.13.0 起" + +### 结构 + +```json +{ + "type": "naive", + "tag": "naive-out", + + "server": "127.0.0.1", + "server_port": 443, + "username": "sekai", + "password": "password", + "insecure_concurrency": 0, + "extra_headers": {}, + "udp_over_tcp": false | {}, + "quic": false, + "quic_congestion_control": "", + "tls": {}, + + ... // 拨号字段 +} +``` + +!!! warning "平台支持" + + NaiveProxy 出站仅在 Apple 平台、Android、Windows 和特定 Linux 构建上可用。 + + **官方发布版本区别:** + + | 构建变体 | 平台 | 说明 | + |---|---|---| + | (无后缀) | Linux amd64/arm64 | purego 构建,包含 `libcronet.so` | + | `-glibc` | Linux 386/amd64/arm/arm64/mipsle/mips64le/riscv64/loong64 | CGO 构建,动态链接 glibc,要求 glibc >= 2.31(loong64: >= 2.36) | + | `-musl` | Linux 386/amd64/arm/arm64/mipsle/riscv64/loong64 | CGO 构建,静态链接 musl | + | (无后缀) | Windows amd64/arm64 | purego 构建,包含 `libcronet.dll` | + + 对于 Linux,请根据发行版的 libc 类型选择 glibc 或 musl 变体。 + + **运行时要求:** + + - **Linux purego**:`libcronet.so` 必须位于 sing-box 二进制文件相同目录或系统库路径中 + - **Windows**:`libcronet.dll` 必须位于 `sing-box.exe` 相同目录或 `PATH` 中的任意目录 + + 自行构建请参阅 [从源代码构建](/zh/installation/build-from-source/#with_naive_outbound)。 + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### username + +认证用户名。 + +#### password + +认证密码。 + +#### insecure_concurrency + +并发隧道连接数。多连接使隧道更容易被流量分析检测,违背 NaiveProxy 抵抗流量分析的设计目的。 + +#### extra_headers + +HTTP 请求中发送的额外头部。 + +#### udp_over_tcp + +UDP over TCP 配置。 + +参阅 [UDP Over TCP](/zh/configuration/shared/udp-over-tcp/)。 + +#### quic + +使用 QUIC 代替 HTTP/2。 + +#### quic_congestion_control + +QUIC 拥塞控制算法。 + +| 算法 | 描述 | +|------|------| +| `bbr` | BBR | +| `bbr2` | BBRv2 | +| `cubic` | CUBIC | +| `reno` | New Reno | + +默认使用 `bbr`(NaiveProxy 基于的 Chromium 使用的 QUICHE 的默认值)。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +只有 `server_name`、`certificate`、`certificate_path` 和 `ech` 是被支持的。 + +自签名证书会显著改变流量行为,违背了 NaiveProxy 旨在抵抗流量分析的设计初衷,不应该在生产环境中使用。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/selector.md b/docs/configuration/outbound/selector.md new file mode 100644 index 00000000..ee75358f --- /dev/null +++ b/docs/configuration/outbound/selector.md @@ -0,0 +1,38 @@ +### Structure + +```json +{ + "type": "selector", + "tag": "select", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "default": "proxy-c", + "interrupt_exist_connections": false +} +``` + +!!! quote "" + + The selector can only be controlled through the [Clash API](/configuration/experimental#clash-api-fields) currently. + +### Fields + +#### outbounds + +==Required== + +List of outbound tags to select. + +#### default + +The default outbound tag. The first outbound will be used if empty. + +#### interrupt_exist_connections + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. diff --git a/docs/configuration/outbound/selector.zh.md b/docs/configuration/outbound/selector.zh.md new file mode 100644 index 00000000..520fb15c --- /dev/null +++ b/docs/configuration/outbound/selector.zh.md @@ -0,0 +1,38 @@ +### 结构 + +```json +{ + "type": "selector", + "tag": "select", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "default": "proxy-c", + "interrupt_exist_connections": false +} +``` + +!!! quote "" + + 选择器目前只能通过 [Clash API](/zh/configuration/experimental/clash-api/) 来控制。 + +### 字段 + +#### outbounds + +==必填== + +用于选择的出站标签列表。 + +#### default + +默认的出站标签。默认使用第一个出站。 + +#### interrupt_exist_connections + +当选定的出站发生更改时,中断现有连接。 + +仅入站连接受此设置影响,内部连接将始终被中断。 \ No newline at end of file diff --git a/docs/configuration/outbound/shadowsocks.md b/docs/configuration/outbound/shadowsocks.md new file mode 100644 index 00000000..a088d271 --- /dev/null +++ b/docs/configuration/outbound/shadowsocks.md @@ -0,0 +1,102 @@ +### Structure + +```json +{ + "type": "shadowsocks", + "tag": "ss-out", + + "server": "127.0.0.1", + "server_port": 1080, + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "plugin": "", + "plugin_opts": "", + "network": "udp", + "udp_over_tcp": false | {}, + "multiplex": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### method + +==Required== + +Encryption methods: + +* `2022-blake3-aes-128-gcm` +* `2022-blake3-aes-256-gcm` +* `2022-blake3-chacha20-poly1305` +* `none` +* `aes-128-gcm` +* `aes-192-gcm` +* `aes-256-gcm` +* `chacha20-ietf-poly1305` +* `xchacha20-ietf-poly1305` + +Legacy encryption methods: + +* `aes-128-ctr` +* `aes-192-ctr` +* `aes-256-ctr` +* `aes-128-cfb` +* `aes-192-cfb` +* `aes-256-cfb` +* `rc4-md5` +* `chacha20-ietf` +* `xchacha20` + +#### password + +==Required== + +The shadowsocks password. + +#### plugin + +Shadowsocks SIP003 plugin, implemented in internal. + +Only `obfs-local` and `v2ray-plugin` are supported. + +#### plugin_opts + +Shadowsocks SIP003 plugin options. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### udp_over_tcp + +UDP over TCP configuration. + +See [UDP Over TCP](/configuration/shared/udp-over-tcp/) for details. + +Conflict with `multiplex`. + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#outbound) for details. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/shadowsocks.zh.md b/docs/configuration/outbound/shadowsocks.zh.md new file mode 100644 index 00000000..7b4ff560 --- /dev/null +++ b/docs/configuration/outbound/shadowsocks.zh.md @@ -0,0 +1,102 @@ +### 结构 + +```json +{ + "type": "shadowsocks", + "tag": "ss-out", + + "server": "127.0.0.1", + "server_port": 1080, + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "plugin": "", + "plugin_opts": "", + "network": "udp", + "udp_over_tcp": false | {}, + "multiplex": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### method + +==必填== + +加密方法: + +* `2022-blake3-aes-128-gcm` +* `2022-blake3-aes-256-gcm` +* `2022-blake3-chacha20-poly1305` +* `none` +* `aes-128-gcm` +* `aes-192-gcm` +* `aes-256-gcm` +* `chacha20-ietf-poly1305` +* `xchacha20-ietf-poly1305` + +旧加密方法: + +* `aes-128-ctr` +* `aes-192-ctr` +* `aes-256-ctr` +* `aes-128-cfb` +* `aes-192-cfb` +* `aes-256-cfb` +* `rc4-md5` +* `chacha20-ietf` +* `xchacha20` + +#### password + +==必填== + +Shadowsocks 密码。 + +#### plugin + +Shadowsocks SIP003 插件,由内部实现。 + +仅支持 `obfs-local` 和 `v2ray-plugin`。 + +#### plugin_opts + +Shadowsocks SIP003 插件参数。 + +#### network + +启用的网络协议 + +`tcp` 或 `udp`。 + +默认所有。 + +#### udp_over_tcp + +UDP over TCP 配置。 + +参阅 [UDP Over TCP](/zh/configuration/shared/udp-over-tcp/)。 + +与 `multiplex` 冲突。 + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/shadowtls.md b/docs/configuration/outbound/shadowtls.md new file mode 100644 index 00000000..a54391b5 --- /dev/null +++ b/docs/configuration/outbound/shadowtls.md @@ -0,0 +1,56 @@ +### Structure + +```json +{ + "type": "shadowtls", + "tag": "st-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": 3, + "password": "fuck me till the daylight", + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### version + +ShadowTLS protocol version. + +| Value | Protocol Version | +|---------------|-----------------------------------------------------------------------------------------| +| `1` (default) | [ShadowTLS v1](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v1) | +| `2` | [ShadowTLS v2](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v2) | +| `3` | [ShadowTLS v3](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-v3-en.md) | + +#### password + +Set password. + +Only available in the ShadowTLS v2/v3 protocol. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/shadowtls.zh.md b/docs/configuration/outbound/shadowtls.zh.md new file mode 100644 index 00000000..72a73d7d --- /dev/null +++ b/docs/configuration/outbound/shadowtls.zh.md @@ -0,0 +1,56 @@ +### 结构 + +```json +{ + "type": "shadowtls", + "tag": "st-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": 3, + "password": "fuck me till the daylight", + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### version + +ShadowTLS 协议版本。 + +| 值 | 协议版本 | +|---------------|-----------------------------------------------------------------------------------------| +| `1` (default) | [ShadowTLS v1](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v1) | +| `2` | [ShadowTLS v2](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-en.md#v2) | +| `3` | [ShadowTLS v3](https://github.com/ihciah/shadow-tls/blob/master/docs/protocol-v3-en.md) | + +#### password + +设置密码。 + +仅在 ShadowTLS v2/v3 协议中可用。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/socks.md b/docs/configuration/outbound/socks.md new file mode 100644 index 00000000..b04e67b6 --- /dev/null +++ b/docs/configuration/outbound/socks.md @@ -0,0 +1,66 @@ +`socks` outbound is a socks4/socks4a/socks5 client. + +### Structure + +```json +{ + "type": "socks", + "tag": "socks-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": "5", + "username": "sekai", + "password": "admin", + "network": "udp", + "udp_over_tcp": false | {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### version + +The SOCKS version, one of `4` `4a` `5`. + +SOCKS5 used by default. + +#### username + +SOCKS username. + +#### password + +SOCKS5 password. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### udp_over_tcp + +UDP over TCP protocol settings. + +See [UDP Over TCP](/configuration/shared/udp-over-tcp/) for details. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/socks.zh.md b/docs/configuration/outbound/socks.zh.md new file mode 100644 index 00000000..dd9a1ac9 --- /dev/null +++ b/docs/configuration/outbound/socks.zh.md @@ -0,0 +1,66 @@ +`socks` 出站是 socks4/socks4a/socks5 客户端 + +### 结构 + +```json +{ + "type": "socks", + "tag": "socks-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": "5", + "username": "sekai", + "password": "admin", + "network": "udp", + "udp_over_tcp": false | {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### version + +SOCKS 版本, 可为 `4` `4a` `5`. + +默认使用 SOCKS5。 + +#### username + +SOCKS 用户名。 + +#### password + +SOCKS5 密码。 + +#### network + +启用的网络协议 + +`tcp` 或 `udp`。 + +默认所有。 + +#### udp_over_tcp + +UDP over TCP 配置。 + +参阅 [UDP Over TCP](/zh/configuration/shared/udp-over-tcp/)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/ssh.md b/docs/configuration/outbound/ssh.md new file mode 100644 index 00000000..45ec72b2 --- /dev/null +++ b/docs/configuration/outbound/ssh.md @@ -0,0 +1,71 @@ +### Structure + +```json +{ + "type": "ssh", + "tag": "ssh-out", + + "server": "127.0.0.1", + "server_port": 22, + "user": "root", + "password": "admin", + "private_key": "", + "private_key_path": "$HOME/.ssh/id_rsa", + "private_key_passphrase": "", + "host_key": [ + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdH..." + ], + "host_key_algorithms": [], + "client_version": "SSH-2.0-OpenSSH_7.4p1", + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +Server address. + +#### server_port + +Server port. 22 will be used if empty. + +#### user + +SSH user, root will be used if empty. + +#### password + +Password. + +#### private_key + +Private key. + +#### private_key_path + +Private key path. + +#### private_key_passphrase + +Private key passphrase. + +#### host_key + +Host key. Accept any if empty. + +#### host_key_algorithms + +Host key algorithms. + +#### client_version + +Client version. Random version will be used if empty. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/ssh.zh.md b/docs/configuration/outbound/ssh.zh.md new file mode 100644 index 00000000..e538e64a --- /dev/null +++ b/docs/configuration/outbound/ssh.zh.md @@ -0,0 +1,71 @@ +### 结构 + +```json +{ + "type": "ssh", + "tag": "ssh-out", + + "server": "127.0.0.1", + "server_port": 22, + "user": "root", + "password": "admin", + "private_key": "", + "private_key_path": "$HOME/.ssh/id_rsa", + "private_key_passphrase": "", + "host_key": [ + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdH..." + ], + "host_key_algorithms": [], + "client_version": "SSH-2.0-OpenSSH_7.4p1", + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +服务器端口,默认使用 22。 + +#### user + +SSH 用户, 默认使用 root。 + +#### password + +密码。 + +#### private_key + +密钥。 + +#### private_key_path + +密钥路径。 + +#### private_key_passphrase + +密钥密码。 + +#### host_key + +主机密钥,留空接受所有。 + +#### host_key_algorithms + +主机密钥算法。 + +#### client_version + +客户端版本,默认使用随机值。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/tor.md b/docs/configuration/outbound/tor.md new file mode 100644 index 00000000..ac778335 --- /dev/null +++ b/docs/configuration/outbound/tor.md @@ -0,0 +1,51 @@ +### Structure + +```json +{ + "type": "tor", + "tag": "tor-out", + + "executable_path": "/usr/bin/tor", + "extra_args": [], + "data_directory": "$HOME/.cache/tor", + "torrc": { + "ClientOnly": 1 + }, + + ... // Dial Fields +} +``` + +!!! info "" + + Embedded Tor is not included by default, see [Installation](/installation/build-from-source/#build-tags). + +### Fields + +#### executable_path + +The path to the Tor executable. + +Embedded Tor will be ignored if set. + +#### extra_args + +List of extra arguments passed to the Tor instance when started. + +#### data_directory + +==Recommended== + +The data directory of Tor. + +Each start will be very slow if not specified. + +#### torrc + +Map of torrc options. + +See [tor(1)](https://linux.die.net/man/1/tor) for details. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/tor.zh.md b/docs/configuration/outbound/tor.zh.md new file mode 100644 index 00000000..a49eb323 --- /dev/null +++ b/docs/configuration/outbound/tor.zh.md @@ -0,0 +1,51 @@ +### 结构 + +```json +{ + "type": "tor", + "tag": "tor-out", + + "executable_path": "/usr/bin/tor", + "extra_args": [], + "data_directory": "$HOME/.cache/tor", + "torrc": { + "ClientOnly": 1 + }, + + ... // 拨号字段 +} +``` + +!!! info "" + + 默认安装不包含嵌入式 Tor, 参阅 [安装](/zh/installation/build-from-source/#构建标记)。 + +### 字段 + +#### executable_path + +Tor 可执行文件路径 + +如果设置,将覆盖嵌入式 Tor。 + +#### extra_args + +启动 Tor 时传递的附加参数列表。 + +#### data_directory + +==推荐== + +Tor 的数据目录。 + +如未设置,每次启动都需要长时间。 + +#### torrc + +torrc 参数表。 + +参阅 [tor(1)](https://linux.die.net/man/1/tor)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/trojan.md b/docs/configuration/outbound/trojan.md new file mode 100644 index 00000000..6a45fd02 --- /dev/null +++ b/docs/configuration/outbound/trojan.md @@ -0,0 +1,62 @@ +### Structure + +```json +{ + "type": "trojan", + "tag": "trojan-out", + + "server": "127.0.0.1", + "server_port": 1080, + "password": "8JCsPssfgS8tiRwiMlhARg==", + "network": "tcp", + "tls": {}, + "multiplex": {}, + "transport": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### password + +==Required== + +The Trojan password. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#outbound) for details. + +#### transport + +V2Ray Transport configuration, see [V2Ray Transport](/configuration/shared/v2ray-transport/). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/trojan.zh.md b/docs/configuration/outbound/trojan.zh.md new file mode 100644 index 00000000..8a78ca2d --- /dev/null +++ b/docs/configuration/outbound/trojan.zh.md @@ -0,0 +1,62 @@ +### 结构 + +```json +{ + "type": "trojan", + "tag": "trojan-out", + + "server": "127.0.0.1", + "server_port": 1080, + "password": "8JCsPssfgS8tiRwiMlhARg==", + "network": "tcp", + "tls": {}, + "multiplex": {}, + "transport": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### password + +==必填== + +Trojan 密码。 + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 + +#### transport + +V2Ray 传输配置,参阅 [V2Ray 传输层](/zh/configuration/shared/v2ray-transport/)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/tuic.md b/docs/configuration/outbound/tuic.md new file mode 100644 index 00000000..4f4ef485 --- /dev/null +++ b/docs/configuration/outbound/tuic.md @@ -0,0 +1,96 @@ +### Structure + +```json +{ + "type": "tuic", + "tag": "tuic-out", + + "server": "127.0.0.1", + "server_port": 1080, + "uuid": "2DD61D93-75D8-4DA4-AC0E-6AECE7EAC365", + "password": "hello", + "congestion_control": "cubic", + "udp_relay_mode": "native", + "udp_over_stream": false, + "zero_rtt_handshake": false, + "heartbeat": "10s", + "network": "tcp", + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### uuid + +==Required== + +TUIC user uuid + +#### password + +TUIC user password + +#### congestion_control + +QUIC congestion control algorithm + +One of: `cubic`, `new_reno`, `bbr` + +`cubic` is used by default. + +#### udp_relay_mode + +UDP packet relay mode + +| Mode | Description | +|:-------|:-------------------------------------------------------------------------| +| native | native UDP characteristics | +| quic | lossless UDP relay using QUIC streams, additional overhead is introduced | + +`native` is used by default. + +Conflict with `udp_over_stream`. + +#### udp_over_stream + +This is the TUIC port of the [UDP over TCP protocol](/configuration/shared/udp-over-tcp/), designed to provide a QUIC +stream based UDP relay mode that TUIC does not provide. Since it is an add-on protocol, you will need to use sing-box or +another program compatible with the protocol as a server. + +This mode has no positive effect in a proper UDP proxy scenario and should only be applied to relay streaming UDP +traffic (basically QUIC streams). + +Conflict with `udp_relay_mode`. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/tuic.zh.md b/docs/configuration/outbound/tuic.zh.md new file mode 100644 index 00000000..6d31d7bc --- /dev/null +++ b/docs/configuration/outbound/tuic.zh.md @@ -0,0 +1,104 @@ +### 结构 + +```json +{ + "type": "tuic", + "tag": "tuic-out", + + "server": "127.0.0.1", + "server_port": 1080, + "uuid": "2DD61D93-75D8-4DA4-AC0E-6AECE7EAC365", + "password": "hello", + "congestion_control": "cubic", + "udp_relay_mode": "native", + "udp_over_stream": false, + "zero_rtt_handshake": false, + "heartbeat": "10s", + "network": "tcp", + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### uuid + +==必填== + +TUIC 用户 UUID + +#### password + +TUIC 用户密码 + +#### congestion_control + +QUIC 拥塞控制算法 + +可选值: `cubic`, `new_reno`, `bbr` + +默认使用 `cubic`。 + +#### udp_relay_mode + +UDP 包中继模式 + +| 模式 | 描述 | +|--------|------------------------------| +| native | 原生 UDP | +| quic | 使用 QUIC 流的无损 UDP 中继,引入了额外的开销 | + +与 `udp_over_stream` 冲突。 + +#### udp_over_stream + +这是 TUIC 的 [UDP over TCP 协议](/zh/configuration/shared/udp-over-tcp/) 移植, 旨在提供 TUIC 不提供的 基于 QUIC 流的 UDP 中继模式。 由于它是一个附加协议,因此您需要使用 sing-box 或其他兼容的程序作为服务器。 + +此模式在正确的 UDP 代理场景中没有任何积极作用,仅适用于中继流式 UDP 流量(基本上是 QUIC 流)。 + +与 `udp_relay_mode` 冲突。 + +#### zero_rtt_handshake + +在客户端启用 0-RTT QUIC 连接握手 +这对性能影响不大,因为协议是完全复用的 + +!!! warning "" +强烈建议禁用此功能,因为它容易受到重放攻击。 +请参阅 [Attack of the clones](https://blog.cloudflare.com/even-faster-connection-establishment-with-quic-0-rtt-resumption/#attack-of-the-clones) + +#### heartbeat + +发送心跳包以保持连接存活的时间间隔 + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/urltest.md b/docs/configuration/outbound/urltest.md new file mode 100644 index 00000000..f4b3b0aa --- /dev/null +++ b/docs/configuration/outbound/urltest.md @@ -0,0 +1,49 @@ +### Structure + +```json +{ + "type": "urltest", + "tag": "auto", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "url": "", + "interval": "", + "tolerance": 0, + "idle_timeout": "", + "interrupt_exist_connections": false +} +``` + +### Fields + +#### outbounds + +==Required== + +List of outbound tags to test. + +#### url + +The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. + +#### interval + +The test interval. `3m` will be used if empty. + +#### tolerance + +The test tolerance in milliseconds. `50` will be used if empty. + +#### idle_timeout + +The idle timeout. `30m` will be used if empty. + +#### interrupt_exist_connections + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. diff --git a/docs/configuration/outbound/urltest.zh.md b/docs/configuration/outbound/urltest.zh.md new file mode 100644 index 00000000..4372298a --- /dev/null +++ b/docs/configuration/outbound/urltest.zh.md @@ -0,0 +1,49 @@ +### 结构 + +```json +{ + "type": "urltest", + "tag": "auto", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "url": "", + "interval": "", + "tolerance": 50, + "idle_timeout": "", + "interrupt_exist_connections": false +} +``` + +### 字段 + +#### outbounds + +==必填== + +用于测试的出站标签列表。 + +#### url + +用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 + +#### interval + +测试间隔。 默认使用 `3m`。 + +#### tolerance + +以毫秒为单位的测试容差。 默认使用 `50`。 + +#### idle_timeout + +空闲超时。默认使用 `30m`。 + +#### interrupt_exist_connections + +当选定的出站发生更改时,中断现有连接。 + +仅入站连接受此设置影响,内部连接将始终被中断。 \ No newline at end of file diff --git a/docs/configuration/outbound/vless.md b/docs/configuration/outbound/vless.md new file mode 100644 index 00000000..28134e55 --- /dev/null +++ b/docs/configuration/outbound/vless.md @@ -0,0 +1,82 @@ +### Structure + +```json +{ + "type": "vless", + "tag": "vless-out", + + "server": "127.0.0.1", + "server_port": 1080, + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "flow": "xtls-rprx-vision", + "network": "tcp", + "tls": {}, + "packet_encoding": "", + "multiplex": {}, + "transport": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### uuid + +==Required== + +VLESS user id. + +#### flow + +VLESS Sub-protocol. + +Available values: + +* `xtls-rprx-vision` + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +#### packet_encoding + +UDP packet encoding, xudp is used by default. + +| Encoding | Description | +|------------|-----------------------| +| (none) | Disabled | +| packetaddr | Supported by v2ray 5+ | +| xudp | Supported by xray | + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#outbound) for details. + +#### transport + +V2Ray Transport configuration, see [V2Ray Transport](/configuration/shared/v2ray-transport/). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/vless.zh.md b/docs/configuration/outbound/vless.zh.md new file mode 100644 index 00000000..f3bc9a08 --- /dev/null +++ b/docs/configuration/outbound/vless.zh.md @@ -0,0 +1,82 @@ +### 结构 + +```json +{ + "type": "vless", + "tag": "vless-out", + + "server": "127.0.0.1", + "server_port": 1080, + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "flow": "xtls-rprx-vision", + "network": "tcp", + "tls": {}, + "packet_encoding": "", + "multiplex": {}, + "transport": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### uuid + +==必填== + +VLESS 用户 ID。 + +#### flow + +VLESS 子协议。 + +可用值: + +* `xtls-rprx-vision` + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +#### packet_encoding + +UDP 包编码,默认使用 xudp。 + +| 编码 | 描述 | +|------------|---------------| +| (空) | 禁用 | +| packetaddr | 由 v2ray 5+ 支持 | +| xudp | 由 xray 支持 | + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 + +#### transport + +V2Ray 传输配置,参阅 [V2Ray 传输层](/zh/configuration/shared/v2ray-transport/)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/vmess.md b/docs/configuration/outbound/vmess.md new file mode 100644 index 00000000..536601af --- /dev/null +++ b/docs/configuration/outbound/vmess.md @@ -0,0 +1,107 @@ +### Structure + +```json +{ + "type": "vmess", + "tag": "vmess-out", + + "server": "127.0.0.1", + "server_port": 1080, + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "security": "auto", + "alter_id": 0, + "global_padding": false, + "authenticated_length": true, + "network": "tcp", + "tls": {}, + "packet_encoding": "", + "transport": {}, + "multiplex": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### uuid + +==Required== + +The VMess user id. + +#### security + +Encryption methods: + +* `auto` +* `none` +* `zero` +* `aes-128-gcm` +* `chacha20-poly1305` + +Legacy encryption methods: + +* `aes-128-ctr` + +#### alter_id + +| Alter ID | Description | +|----------|---------------------| +| 0 | Use AEAD protocol | +| 1 | Use legacy protocol | +| > 1 | Unused, same as 1 | + +#### global_padding + +Protocol parameter. Will waste traffic randomly if enabled (enabled by default in v2ray and cannot be disabled). + +#### authenticated_length + +Protocol parameter. Enable length block encryption. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +#### packet_encoding + +UDP packet encoding. + +| Encoding | Description | +|------------|-----------------------| +| (none) | Disabled | +| packetaddr | Supported by v2ray 5+ | +| xudp | Supported by xray | + +#### multiplex + +See [Multiplex](/configuration/shared/multiplex#outbound) for details. + +#### transport + +V2Ray Transport configuration, see [V2Ray Transport](/configuration/shared/v2ray-transport/). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/vmess.zh.md b/docs/configuration/outbound/vmess.zh.md new file mode 100644 index 00000000..cbc8bdee --- /dev/null +++ b/docs/configuration/outbound/vmess.zh.md @@ -0,0 +1,107 @@ +### 结构 + +```json +{ + "type": "vmess", + "tag": "vmess-out", + + "server": "127.0.0.1", + "server_port": 1080, + "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", + "security": "auto", + "alter_id": 0, + "global_padding": false, + "authenticated_length": true, + "network": "tcp", + "tls": {}, + "packet_encoding": "", + "multiplex": {}, + "transport": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### uuid + +==必填== + +VMess 用户 ID。 + +#### security + +加密方法: + +* `auto` +* `none` +* `zero` +* `aes-128-gcm` +* `chacha20-poly1305` + +旧加密方法: + +* `aes-128-ctr` + +#### alter_id + +| Alter ID | 描述 | +|----------|------------| +| 0 | 禁用旧协议 | +| 1 | 启用旧协议 | +| > 1 | 未使用, 行为同 1 | + +#### global_padding + +协议参数。 如果启用会随机浪费流量(在 v2ray 中默认启用并且无法禁用)。 + +#### authenticated_length + +协议参数。启用长度块加密。 + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### tls + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 + +#### packet_encoding + +UDP 包编码。 + +| 编码 | 描述 | +|------------|---------------| +| (空) | 禁用 | +| packetaddr | 由 v2ray 5+ 支持 | +| xudp | 由 xray 支持 | + +#### multiplex + +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 + +#### transport + +V2Ray 传输配置,参阅 [V2Ray 传输层](/zh/configuration/shared/v2ray-transport/)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/wireguard.md b/docs/configuration/outbound/wireguard.md new file mode 100644 index 00000000..648ba607 --- /dev/null +++ b/docs/configuration/outbound/wireguard.md @@ -0,0 +1,168 @@ +--- +icon: material/delete-clock +--- + +!!! failure "Deprecated in sing-box 1.11.0" + + WireGuard outbound is deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-wireguard-outbound-to-endpoint). + +!!! quote "Changes in sing-box 1.11.0" + + :material-delete-alert: [gso](#gso) + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [gso](#gso) + +### Structure + +```json +{ + "type": "wireguard", + "tag": "wireguard-out", + + "server": "127.0.0.1", + "server_port": 1080, + "system_interface": false, + "interface_name": "wg0", + "local_address": [ + "10.0.0.1/32" + ], + "private_key": "YNXtAzepDqRv9H52osJVDQnznT5AM11eCK3ESpwSt04=", + "peers": [ + { + "server": "127.0.0.1", + "server_port": 1080, + "public_key": "Z1XXLsKYkYxuiYjJIkRvtIKFepCYHTgON+GwPq7SOV4=", + "pre_shared_key": "31aIhAPwktDGpH4JDhA8GNvjFXEf/a6+UaQRyOAiyfM=", + "allowed_ips": [ + "0.0.0.0/0" + ], + "reserved": [0, 0, 0] + } + ], + "peer_public_key": "Z1XXLsKYkYxuiYjJIkRvtIKFepCYHTgON+GwPq7SOV4=", + "pre_shared_key": "31aIhAPwktDGpH4JDhA8GNvjFXEf/a6+UaQRyOAiyfM=", + "reserved": [0, 0, 0], + "workers": 4, + "mtu": 1408, + "network": "tcp", + + // Deprecated + + "gso": false, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required if multi-peer disabled== + +The server address. + +#### server_port + +==Required if multi-peer disabled== + +The server port. + +#### system_interface + +Use system interface. + +Requires privilege and cannot conflict with exists system interfaces. + +Forced if gVisor not included in the build. + +#### interface_name + +Custom interface name for system interface. + +#### gso + +!!! failure "Deprecated in sing-box 1.11.0" + + GSO will be automatically enabled when available since sing-box 1.11.0. + +!!! question "Since sing-box 1.8.0" + +!!! quote "" + + Only supported on Linux. + +Try to enable generic segmentation offload. + +#### local_address + +==Required== + +List of IP (v4 or v6) address prefixes to be assigned to the interface. + +#### private_key + +==Required== + +WireGuard requires base64-encoded public and private keys. These can be generated using the wg(8) utility: + +```shell +wg genkey +echo "private key" || wg pubkey +``` + +#### peers + +Multi-peer support. + +If enabled, `server, server_port, peer_public_key, pre_shared_key` will be ignored. + +#### peers.allowed_ips + +WireGuard allowed IPs. + +#### peers.reserved + +WireGuard reserved field bytes. + +`$outbound.reserved` will be used if empty. + +#### peer_public_key + +==Required if multi-peer disabled== + +WireGuard peer public key. + +#### pre_shared_key + +WireGuard pre-shared key. + +#### reserved + +WireGuard reserved field bytes. + +#### workers + +WireGuard worker count. + +CPU count is used by default. + +#### mtu + +WireGuard MTU. + +1408 will be used if empty. + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/wireguard.zh.md b/docs/configuration/outbound/wireguard.zh.md new file mode 100644 index 00000000..2b6d4a0a --- /dev/null +++ b/docs/configuration/outbound/wireguard.zh.md @@ -0,0 +1,142 @@ +--- +icon: material/delete-clock +--- + +!!! failure "已在 sing-box 1.11.0 废弃" + + WireGuard 出站已被弃用,且将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-wireguard-出站到端点)。 + +!!! quote "sing-box 1.11.0 中的更改" + + :material-delete-alert: [gso](#gso) + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [gso](#gso) + +### 结构 + +```json +{ + "type": "wireguard", + "tag": "wireguard-out", + + "server": "127.0.0.1", + "server_port": 1080, + "system_interface": false, + "interface_name": "wg0", + "local_address": [ + "10.0.0.1/32" + ], + "private_key": "YNXtAzepDqRv9H52osJVDQnznT5AM11eCK3ESpwSt04=", + "peer_public_key": "Z1XXLsKYkYxuiYjJIkRvtIKFepCYHTgON+GwPq7SOV4=", + "pre_shared_key": "31aIhAPwktDGpH4JDhA8GNvjFXEf/a6+UaQRyOAiyfM=", + "reserved": [0, 0, 0], + "workers": 4, + "mtu": 1408, + "network": "tcp", + + // 废弃的 + + "gso": false, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### system_interface + +使用系统设备。 + +需要特权且不能与已有系统接口冲突。 + +如果 gVisor 未包含在构建中,则强制执行。 + +#### interface_name + +为系统接口自定义设备名称。 + +#### gso + +!!! failure "已在 sing-box 1.11.0 废弃" + + 自 sing-box 1.11.0 起,GSO 将可用时自动启用。 + +!!! question "自 sing-box 1.8.0 起" + +!!! quote "" + + 仅支持 Linux。 + +尝试启用通用分段卸载。 + +#### local_address + +==必填== + +接口的 IPv4/IPv6 地址或地址段的列表。 + +要分配给接口的 IP(v4 或 v6)地址段列表。 + +#### private_key + +==必填== + +WireGuard 需要 base64 编码的公钥和私钥。 这些可以使用 wg(8) 实用程序生成: + +```shell +wg genkey +echo "private key" || wg pubkey +``` + +#### peer_public_key + +==必填== + +WireGuard 对等公钥。 + +#### pre_shared_key + +WireGuard 预共享密钥。 + +#### reserved + +WireGuard 保留字段字节。 + +#### workers + +WireGuard worker 数量。 + +默认使用 CPU 数量。 + +#### mtu + +WireGuard MTU。 + +默认使用 1408。 + +#### network + +启用的网络协议 + +`tcp` 或 `udp`。 + +默认所有。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/route/geoip.md b/docs/configuration/route/geoip.md new file mode 100644 index 00000000..62162cdf --- /dev/null +++ b/docs/configuration/route/geoip.md @@ -0,0 +1,41 @@ +--- +icon: material/note-remove +--- + +!!! failure "Removed in sing-box 1.12.0" + + GeoIP is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geoip-to-rule-sets). + +### Structure + +```json +{ + "route": { + "geoip": { + "path": "", + "download_url": "", + "download_detour": "" + } + } +} +``` + +### Fields + +#### path + +The path to the sing-geoip database. + +`geoip.db` will be used if empty. + +#### download_url + +The download URL of the sing-geoip database. + +Default is `https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db`. + +#### download_detour + +The tag of the outbound to download the database. + +Default outbound will be used if empty. \ No newline at end of file diff --git a/docs/configuration/route/geoip.zh.md b/docs/configuration/route/geoip.zh.md new file mode 100644 index 00000000..17559a46 --- /dev/null +++ b/docs/configuration/route/geoip.zh.md @@ -0,0 +1,41 @@ +--- +icon: material/note-remove +--- + +!!! failure "已在 sing-box 1.12.0 中被移除" + + GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 + +### 结构 + +```json +{ + "route": { + "geoip": { + "path": "", + "download_url": "", + "download_detour": "" + } + } +} +``` + +### 字段 + +#### path + +指定 GeoIP 资源的路径。 + +默认 `geoip.db`。 + +#### download_url + +指定 GeoIP 资源的下载链接。 + +默认为 `https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db`。 + +#### download_detour + +用于下载 GeoIP 资源的出站的标签。 + +如果为空,将使用默认出站。 \ No newline at end of file diff --git a/docs/configuration/route/geosite.md b/docs/configuration/route/geosite.md new file mode 100644 index 00000000..830d1158 --- /dev/null +++ b/docs/configuration/route/geosite.md @@ -0,0 +1,41 @@ +--- +icon: material/note-remove +--- + +!!! failure "Removed in sing-box 1.12.0" + + Geosite is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geosite-to-rule-sets). + +### Structure + +```json +{ + "route": { + "geosite": { + "path": "", + "download_url": "", + "download_detour": "" + } + } +} +``` + +### Fields + +#### path + +The path to the sing-geosite database. + +`geosite.db` will be used if empty. + +#### download_url + +The download URL of the sing-geoip database. + +Default is `https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db`. + +#### download_detour + +The tag of the outbound to download the database. + +Default outbound will be used if empty. \ No newline at end of file diff --git a/docs/configuration/route/geosite.zh.md b/docs/configuration/route/geosite.zh.md new file mode 100644 index 00000000..1ea0752a --- /dev/null +++ b/docs/configuration/route/geosite.zh.md @@ -0,0 +1,41 @@ +--- +icon: material/note-remove +--- + +!!! failure "已在 sing-box 1.12.0 中被移除" + + Geosite 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 + +### 结构 + +```json +{ + "route": { + "geosite": { + "path": "", + "download_url": "", + "download_detour": "" + } + } +} +``` + +### 字段 + +#### path + +指定 GeoSite 资源的路径。 + +默认 `geosite.db`。 + +#### download_url + +指定 GeoSite 资源的下载链接。 + +默认为 `https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db`。 + +#### download_detour + +用于下载 GeoSite 资源的出站的标签。 + +如果为空,将使用默认出站。 \ No newline at end of file diff --git a/docs/configuration/route/index.md b/docs/configuration/route/index.md new file mode 100644 index 00000000..6c59f850 --- /dev/null +++ b/docs/configuration/route/index.md @@ -0,0 +1,186 @@ +--- +icon: material/alert-decagram +--- + +# Route + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [find_neighbor](#find_neighbor) + :material-plus: [dhcp_lease_files](#dhcp_lease_files) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [default_domain_resolver](#default_domain_resolver) + :material-note-remove: [geoip](#geoip) + :material-note-remove: [geosite](#geosite) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [default_network_strategy](#default_network_strategy) + :material-plus: [default_network_type](#default_network_type) + :material-plus: [default_fallback_network_type](#default_fallback_network_type) + :material-plus: [default_fallback_delay](#default_fallback_delay) + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [rule_set](#rule_set) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + +### Structure + +```json +{ + "route": { + "rules": [], + "rule_set": [], + "final": "", + "auto_detect_interface": false, + "override_android_vpn": false, + "default_interface": "", + "default_mark": 0, + "find_process": false, + "find_neighbor": false, + "dhcp_lease_files": [], + "default_domain_resolver": "", // or {} + "default_network_strategy": "", + "default_network_type": [], + "default_fallback_network_type": [], + "default_fallback_delay": "", + + // Removed + + "geoip": {}, + "geosite": {} + } +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### rules + +List of [Route Rule](./rule/) + +#### rule_set + +!!! question "Since sing-box 1.8.0" + +List of [rule-set](/configuration/rule-set/) + +#### final + +Default outbound tag. the first outbound will be used if empty. + +#### auto_detect_interface + +!!! quote "" + + Only supported on Linux, Windows and macOS. + +Bind outbound connections to the default NIC by default to prevent routing loops under tun. + +Takes no effect if `outbound.bind_interface` is set. + +#### override_android_vpn + +!!! quote "" + + Only supported on Android. + +Accept Android VPN as upstream NIC when `auto_detect_interface` enabled. + +#### default_interface + +!!! quote "" + + Only supported on Linux, Windows and macOS. + +Bind outbound connections to the specified NIC by default to prevent routing loops under tun. + +Takes no effect if `auto_detect_interface` is set. + +#### default_mark + +!!! quote "" + + Only supported on Linux. + +Set routing mark by default. + +Takes no effect if `outbound.routing_mark` is set. + +#### find_process + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Enable process search for logging when no `process_name`, `process_path`, `package_name`, `user` or `user_id` rules exist. + +#### find_neighbor + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux and macOS. + +Enable neighbor resolution for logging when no `source_mac_address` or `source_hostname` rules exist. + +See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +#### dhcp_lease_files + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux and macOS. + +Custom DHCP lease file paths for hostname and MAC address resolution. + +Automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea) if empty. + +#### default_domain_resolver + +!!! question "Since sing-box 1.12.0" + +See [Dial Fields](/configuration/shared/dial/#domain_resolver) for details. + +Can be overridden by `outbound.domain_resolver`. + +#### default_network_strategy + +!!! question "Since sing-box 1.11.0" + +See [Dial Fields](/configuration/shared/dial/#network_strategy) for details. + +Takes no effect if `outbound.bind_interface`, `outbound.inet4_bind_address` or `outbound.inet6_bind_address` is set. + +Can be overridden by `outbound.network_strategy`. + +Conflicts with `default_interface`. + +#### default_network_type + +!!! question "Since sing-box 1.11.0" + +See [Dial Fields](/configuration/shared/dial/#network_type) for details. + +#### default_fallback_network_type + +!!! question "Since sing-box 1.11.0" + +See [Dial Fields](/configuration/shared/dial/#fallback_network_type) for details. + +#### default_fallback_delay + +!!! question "Since sing-box 1.11.0" + +See [Dial Fields](/configuration/shared/dial/#fallback_delay) for details. diff --git a/docs/configuration/route/index.zh.md b/docs/configuration/route/index.zh.md new file mode 100644 index 00000000..4977b084 --- /dev/null +++ b/docs/configuration/route/index.zh.md @@ -0,0 +1,185 @@ +--- +icon: material/alert-decagram +--- + +# 路由 + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [find_neighbor](#find_neighbor) + :material-plus: [dhcp_lease_files](#dhcp_lease_files) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [default_domain_resolver](#default_domain_resolver) + :material-note-remove: [geoip](#geoip) + :material-note-remove: [geosite](#geosite) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [default_network_strategy](#default_network_strategy) + :material-plus: [default_network_type](#default_network_type) + :material-plus: [default_fallback_network_type](#default_fallback_network_type) + :material-plus: [default_fallback_delay](#default_fallback_delay) + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [rule_set](#rule_set) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + +### 结构 + +```json +{ + "route": { + "geoip": {}, + "geosite": {}, + "rules": [], + "rule_set": [], + "final": "", + "auto_detect_interface": false, + "override_android_vpn": false, + "default_interface": "", + "default_mark": 0, + "find_process": false, + "find_neighbor": false, + "dhcp_lease_files": [], + "default_network_strategy": "", + "default_fallback_delay": "" + } +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 字段 + +| 键 | 格式 | +|-----------|-----------------------| +| `geoip` | [GeoIP](./geoip/) | +| `geosite` | [Geosite](./geosite/) | + +#### rule + +一组 [路由规则](./rule/) 。 + +#### rule_set + +!!! question "自 sing-box 1.8.0 起" + +一组 [规则集](/zh/configuration/rule-set/)。 + +#### final + +默认出站标签。如果为空,将使用第一个可用于对应协议的出站。 + +#### auto_detect_interface + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +默认将出站连接绑定到默认网卡,以防止在 tun 下出现路由环路。 + +如果设置了 `outbound.bind_interface` 设置,则不生效。 + +#### override_android_vpn + +!!! quote "" + + 仅支持 Android。 + +启用 `auto_detect_interface` 时接受 Android VPN 作为上游网卡。 + +#### default_interface + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +默认将出站连接绑定到指定网卡,以防止在 tun 下出现路由环路。 + +如果设置了 `auto_detect_interface` 设置,则不生效。 + +#### default_mark + +!!! quote "" + + 仅支持 Linux。 + +默认为出站连接设置路由标记。 + +如果设置了 `outbound.routing_mark` 设置,则不生效。 + +#### find_process + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +在没有 `process_name`、`process_path`、`package_name`、`user` 或 `user_id` 规则时启用进程搜索以输出日志。 + +#### find_neighbor + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux 和 macOS。 + +在没有 `source_mac_address` 或 `source_hostname` 规则时启用邻居解析以输出日志。 + +参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +#### dhcp_lease_files + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux 和 macOS。 + +用于主机名和 MAC 地址解析的自定义 DHCP 租约文件路径。 + +为空时自动从常见 DHCP 服务器(dnsmasq、odhcpd、ISC dhcpd、Kea)检测。 + +#### default_domain_resolver + +!!! question "自 sing-box 1.12.0 起" + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#domain_resolver)。 + +可以被 `outbound.domain_resolver` 覆盖。 + +#### network_strategy + +!!! question "自 sing-box 1.11.0 起" + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#network_strategy)。 + +当 `outbound.bind_interface`, `outbound.inet4_bind_address` 或 `outbound.inet6_bind_address` 已设置时不生效。 + +可以被 `outbound.network_strategy` 覆盖。 + +与 `default_interface` 冲突。 + +#### default_network_type + +!!! question "自 sing-box 1.11.0 起" + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#default_network_type)。 + +#### default_fallback_network_type + +!!! question "自 sing-box 1.11.0 起" + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#default_fallback_network_type)。 + +#### default_fallback_delay + +!!! question "自 sing-box 1.11.0 起" + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#fallback_delay)。 diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md new file mode 100644 index 00000000..97bbe376 --- /dev/null +++ b/docs/configuration/route/rule.md @@ -0,0 +1,547 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + :material-plus: [package_name_regex](#package_name_regex) + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [interface_address](#interface_address) + :material-plus: [network_interface_address](#network_interface_address) + :material-plus: [default_interface_address](#default_interface_address) + :material-plus: [preferred_by](#preferred_by) + :material-alert: [network](#network) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [action](#action) + :material-alert: [outbound](#outbound) + :material-plus: [network_type](#network_type) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) + +!!! quote "Changes in sing-box 1.10.0" + + :material-plus: [client](#client) + :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source) + :material-plus: [process_path_regex](#process_path_regex) + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [rule_set](#rule_set) + :material-plus: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [source_ip_is_private](#source_ip_is_private) + :material-plus: [ip_is_private](#ip_is_private) + :material-delete-clock: [source_geoip](#source_geoip) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + +### Structure + +```json +{ + "route": { + "rules": [ + { + "inbound": [ + "mixed-in" + ], + "ip_version": 6, + "network": [ + "tcp" + ], + "auth_user": [ + "usera", + "userb" + ], + "protocol": [ + "tls", + "http", + "quic" + ], + "client": [ + "chromium", + "safari", + "firefox", + "quic-go" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "geosite": [ + "cn" + ], + "source_geoip": [ + "private" + ], + "geoip": [ + "cn" + ], + "source_ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "source_ip_is_private": false, + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_is_private": false, + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "process_path_regex": [ + "^/usr/bin/.+" + ], + "package_name": [ + "com.termux" + ], + "package_name_regex": [ + "^com\\.termux.*" + ], + "user": [ + "sekai" + ], + "user_id": [ + 1000 + ], + "clash_mode": "direct", + "network_type": [ + "wifi" + ], + "network_is_expensive": false, + "network_is_constrained": false, + "interface_address": { + "en0": [ + "2000::/3" + ] + }, + "network_interface_address": { + "wifi": [ + "2000::/3" + ] + }, + "default_interface_address": [ + "2000::/3" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "preferred_by": [ + "tailscale", + "wireguard" + ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], + // deprecated + "rule_set_ipcidr_match_source": false, + "rule_set_ip_cidr_match_source": false, + "invert": false, + "action": "route", + "outbound": "direct" + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "invert": false, + "action": "route", + "outbound": "direct" + } + ] + } +} + +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Default Fields + +!!! note "" + + The default rule uses the following matching logic: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `geosite` || `geoip` || `ip_cidr` || `ip_is_private`) && + (`port` || `port_range`) && + (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && + (`source_port` || `source_port_range`) && + `other fields` + + Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + +#### inbound + +Tags of [Inbound](/configuration/inbound/). + +#### ip_version + +4 or 6. + +Not limited if empty. + +#### auth_user + +Username, see each inbound for details. + +#### protocol + +Sniffed protocol, see [Protocol Sniff](/configuration/route/sniff/) for details. + +#### client + +!!! question "Since sing-box 1.10.0" + +Sniffed client type, see [Protocol Sniff](/configuration/route/sniff/) for details. + +#### network + +!!! quote "Changes in sing-box 1.13.0" + + Since sing-box 1.13.0, you can match ICMP echo (ping) requests via the new `icmp` network. + + Such traffic originates from `TUN`, `WireGuard`, and `Tailscale` inbounds and can be routed to `Direct`, `WireGuard`, and `Tailscale` outbounds. + +Match network type. + +`tcp`, `udp` or `icmp`. + +#### domain + +Match full domain. + +#### domain_suffix + +Match domain suffix. + +#### domain_keyword + +Match domain using keyword. + +#### domain_regex + +Match domain using regular expression. + +#### geosite + +!!! failure "Deprecated in sing-box 1.8.0" + + Geosite is deprecated and will be removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geosite-to-rule-sets). + +Match geosite. + +#### source_geoip + +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and will be removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geoip-to-rule-sets). + +Match source geoip. + +#### geoip + +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and will be removed in sing-box 1.12.0, check [Migration](/migration/#migrate-geoip-to-rule-sets). + +Match geoip. + +#### source_ip_cidr + +Match source IP CIDR. + +#### ip_is_private + +!!! question "Since sing-box 1.8.0" + +Match non-public IP. + +#### ip_cidr + +Match IP CIDR. + +#### source_ip_is_private + +!!! question "Since sing-box 1.8.0" + +Match non-public source IP. + +#### source_port + +Match source port. + +#### source_port_range + +Match source port range. + +#### port + +Match port. + +#### port_range + +Match port range. + +#### process_name + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process name. + +#### process_path + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path. + +#### process_path_regex + +!!! question "Since sing-box 1.10.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path using regular expression. + +#### package_name + +Match android package name. + +#### package_name_regex + +!!! question "Since sing-box 1.14.0" + +Match android package name using regular expression. + +#### user + +!!! quote "" + + Only supported on Linux. + +Match user name. + +#### user_id + +!!! quote "" + + Only supported on Linux. + +Match user id. + +#### clash_mode + +Match Clash mode. + +#### network_type + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match network type. + +Available values: `wifi`, `cellular`, `ethernet` and `other`. + +#### network_is_expensive + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match if network is considered Metered (on Android) or considered expensive, +such as Cellular or a Personal Hotspot (on Apple platforms). + +#### network_is_constrained + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Apple platforms. + +Match if network is in Low Data Mode. + +#### interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match interface address. + +#### network_interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Matches network interface (same values as `network_type`) address. + +#### default_interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match default interface address. + +#### wifi_ssid + +Match WiFi SSID. + +See [Wi-Fi State](/configuration/shared/wifi-state/) for details. + +#### wifi_bssid + +Match WiFi BSSID. + +See [Wi-Fi State](/configuration/shared/wifi-state/) for details. + +#### preferred_by + +!!! question "Since sing-box 1.13.0" + +Match specified outbounds' preferred routes. + +| Type | Match | +|-------------|-----------------------------------------------| +| `tailscale` | Match MagicDNS domains and peers' allowed IPs | +| `wireguard` | Match peers's allowed IPs | + +#### source_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device MAC address. + +#### source_hostname + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device hostname from DHCP leases. + +#### rule_set + +!!! question "Since sing-box 1.8.0" + +Match [rule-set](/configuration/route/#rule_set). + +#### rule_set_ipcidr_match_source + +!!! question "Since sing-box 1.8.0" + +!!! failure "Deprecated in sing-box 1.10.0" + + `rule_set_ipcidr_match_source` is renamed to `rule_set_ip_cidr_match_source` and will be remove in sing-box 1.11.0. + +Make `ip_cidr` in rule-sets match the source IP. + +#### rule_set_ip_cidr_match_source + +!!! question "Since sing-box 1.10.0" + +Make `ip_cidr` in rule-sets match the source IP. + +#### invert + +Invert match result. + +#### action + +==Required== + +See [Rule Actions](../rule_action/) for details. + +#### outbound + +!!! failure "Deprecated in sing-box 1.11.0" + + Moved to [Rule Action](../rule_action#route). + +### Logical Fields + +#### type + +`logical` + +#### mode + +==Required== + +`and` or `or` + +#### rules + +==Required== + +Included rules. diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md new file mode 100644 index 00000000..d55b565d --- /dev/null +++ b/docs/configuration/route/rule.zh.md @@ -0,0 +1,545 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + :material-plus: [package_name_regex](#package_name_regex) + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [interface_address](#interface_address) + :material-plus: [network_interface_address](#network_interface_address) + :material-plus: [default_interface_address](#default_interface_address) + :material-plus: [preferred_by](#preferred_by) + :material-alert: [network](#network) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [action](#action) + :material-alert: [outbound](#outbound) + :material-plus: [network_type](#network_type) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) + +!!! quote "sing-box 1.10.0 中的更改" + + :material-plus: [client](#client) + :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source) + :material-plus: [process_path_regex](#process_path_regex) + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [rule_set](#rule_set) + :material-plus: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [source_ip_is_private](#source_ip_is_private) + :material-plus: [ip_is_private](#ip_is_private) + :material-delete-clock: [source_geoip](#source_geoip) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + +### 结构 + +```json +{ + "route": { + "rules": [ + { + "inbound": [ + "mixed-in" + ], + "ip_version": 6, + "network": [ + "tcp" + ], + "auth_user": [ + "usera", + "userb" + ], + "protocol": [ + "tls", + "http", + "quic" + ], + "client": [ + "chromium", + "safari", + "firefox", + "quic-go" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "geosite": [ + "cn" + ], + "source_geoip": [ + "private" + ], + "geoip": [ + "cn" + ], + "source_ip_cidr": [ + "10.0.0.0/24" + ], + "source_ip_is_private": false, + "ip_cidr": [ + "10.0.0.0/24" + ], + "ip_is_private": false, + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "process_path_regex": [ + "^/usr/bin/.+" + ], + "package_name": [ + "com.termux" + ], + "package_name_regex": [ + "^com\\.termux.*" + ], + "user": [ + "sekai" + ], + "user_id": [ + 1000 + ], + "clash_mode": "direct", + "network_type": [ + "wifi" + ], + "network_is_expensive": false, + "network_is_constrained": false, + "interface_address": { + "en0": [ + "2000::/3" + ] + }, + "network_interface_address": { + "wifi": [ + "2000::/3" + ] + }, + "default_interface_address": [ + "2000::/3" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "preferred_by": [ + "tailscale", + "wireguard" + ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], + // 已弃用 + "rule_set_ipcidr_match_source": false, + "rule_set_ip_cidr_match_source": false, + "invert": false, + "action": "route", + "outbound": "direct" + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "invert": false, + "action": "route", + "outbound": "direct" + } + ] + } +} + +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### 默认字段 + +!!! note "" + + 默认规则使用以下匹配逻辑: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `geosite` || `geoip` || `ip_cidr` || `ip_is_private`) && + (`port` || `port_range`) && + (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && + (`source_port` || `source_port_range`) && + `other fields` + + 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + +#### inbound + +[入站](/zh/configuration/inbound/) 标签。 + +#### ip_version + +4 或 6。 + +默认不限制。 + +#### auth_user + +认证用户名,参阅入站设置。 + +#### protocol + +探测到的协议, 参阅 [协议探测](/zh/configuration/route/sniff/)。 + +#### client + +!!! question "自 sing-box 1.10.0 起" + +探测到的客户端类型, 参阅 [协议探测](/zh/configuration/route/sniff/)。 + +#### network + +!!! quote "sing-box 1.13.0 中的更改" + + 自 sing-box 1.13.0 起,您可以通过新的 `icmp` 网络匹配 ICMP 回显(ping)请求。 + + 此类流量源自 `TUN`、`WireGuard` 和 `Tailscale` 入站,并可路由至 `Direct`、`WireGuard` 和 `Tailscale` 出站。 + +匹配网络类型。 + +`tcp`、`udp` 或 `icmp`。 + +#### domain + +匹配完整域名。 + +#### domain_suffix + +匹配域名后缀。 + +#### domain_keyword + +匹配域名关键字。 + +#### domain_regex + +匹配域名正则表达式。 + +#### geosite + +!!! failure "已在 sing-box 1.8.0 废弃" + + Geosite 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 + +匹配 Geosite。 + +#### source_geoip + +!!! failure "已在 sing-box 1.8.0 废弃" + + GeoIP 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 + +匹配源 GeoIP。 + +#### geoip + +!!! failure "已在 sing-box 1.8.0 废弃" + + GeoIP 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 + +匹配 GeoIP。 + +#### source_ip_cidr + +匹配源 IP CIDR。 + +#### source_ip_is_private + +!!! question "自 sing-box 1.8.0 起" + +匹配非公开源 IP。 + +#### ip_cidr + +匹配 IP CIDR。 + +#### ip_is_private + +!!! question "自 sing-box 1.8.0 起" + +匹配非公开 IP。 + +#### source_port + +匹配源端口。 + +#### source_port_range + +匹配源端口范围。 + +#### port + +匹配端口。 + +#### port_range + +匹配端口范围。 + +#### process_name + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +匹配进程名称。 + +#### process_path + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配进程路径。 + +#### process_path_regex + +!!! question "自 sing-box 1.10.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +使用正则表达式匹配进程路径。 + +#### package_name + +匹配 Android 应用包名。 + +#### package_name_regex + +!!! question "自 sing-box 1.14.0 起" + +使用正则表达式匹配 Android 应用包名。 + +#### user + +!!! quote "" + + 仅支持 Linux. + +匹配用户名。 + +#### user_id + +!!! quote "" + + 仅支持 Linux. + +匹配用户 ID。 + +#### clash_mode + +匹配 Clash 模式。 + +#### network_type + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配网络类型。 + +可用值: `wifi`, `cellular`, `ethernet` and `other`. + +#### network_is_expensive + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配如果网络被视为计费 (在 Android) 或被视为昂贵, +像蜂窝网络或个人热点 (在 Apple 平台)。 + +#### network_is_constrained + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Apple 平台图形客户端中支持。 + +匹配如果网络在低数据模式下。 + +#### interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配接口地址。 + +#### network_interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配网络接口(可用值同 `network_type`)地址。 + +#### default_interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配默认接口地址。 + +#### wifi_ssid + +匹配 WiFi SSID。 + +参阅 [Wi-Fi 状态](/zh/configuration/shared/wifi-state/)。 + +#### wifi_bssid + +匹配 WiFi BSSID。 + +参阅 [Wi-Fi 状态](/zh/configuration/shared/wifi-state/)。 + +#### preferred_by + +!!! question "自 sing-box 1.13.0 起" + +匹配制定出站的首选路由。 + +| 类型 | 匹配 | +|-------------|--------------------------------| +| `tailscale` | 匹配 MagicDNS 域名和对端的 allowed IPs | +| `wireguard` | 匹配对端的 allowed IPs | + +#### source_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备 MAC 地址。 + +#### source_hostname + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备从 DHCP 租约获取的主机名。 + +#### rule_set + +!!! question "自 sing-box 1.8.0 起" + +匹配[规则集](/zh/configuration/route/#rule_set)。 + +#### rule_set_ipcidr_match_source + +!!! question "自 sing-box 1.8.0 起" + +!!! failure "已在 sing-box 1.10.0 废弃" + + `rule_set_ipcidr_match_source` 已重命名为 `rule_set_ip_cidr_match_source` 且将在 sing-box 1.11.0 中被移除。 + +使规则集中的 `ip_cidr` 规则匹配源 IP。 + +#### rule_set_ip_cidr_match_source + +!!! question "自 sing-box 1.10.0 起" + +使规则集中的 `ip_cidr` 规则匹配源 IP。 + +#### invert + +反选匹配结果。 + +#### action + +==必填== + +参阅 [规则动作](../rule_action/)。 + +#### outbound + +!!! failure "已在 sing-box 1.11.0 废弃" + + 已移动到 [规则动作](../rule_action#route). + +### 逻辑字段 + +#### type + +`logical` + +#### mode + +==必填== + +`and` 或 `or` + +#### rules + +==必填== + +包括的规则。 diff --git a/docs/configuration/route/rule_action.md b/docs/configuration/route/rule_action.md new file mode 100644 index 00000000..1ba69039 --- /dev/null +++ b/docs/configuration/route/rule_action.md @@ -0,0 +1,330 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [bypass](#bypass) + :material-alert: [reject](#reject) + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [resolve.disable_optimistic_cache](#disable_optimistic_cache) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [tls_fragment](#tls_fragment) + :material-plus: [tls_fragment_fallback_delay](#tls_fragment_fallback_delay) + :material-plus: [tls_record_fragment](#tls_record_fragment) + :material-plus: [resolve.disable_cache](#disable_cache) + :material-plus: [resolve.rewrite_ttl](#rewrite_ttl) + :material-plus: [resolve.client_subnet](#client_subnet) + +## Final actions + +### route + +```json +{ + "action": "route", // default + "outbound": "", + + ... // route-options Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +`route` inherits the classic rule behavior of routing connection to the specified outbound. + +#### outbound + +==Required== + +Tag of target outbound. + +#### route-options Fields + +See `route-options` fields below. + +### bypass + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux with `auto_redirect` enabled. + +```json +{ + "action": "bypass", + "outbound": "", + + ... // route-options Fields +} +``` + +`bypass` bypasses sing-box at the kernel level for auto redirect connections in pre-match. + +For non-auto-redirect connections and already established connections, +if `outbound` is specified, the behavior is the same as `route`; +otherwise, the rule will be skipped. + +#### outbound + +Tag of target outbound. + +If not specified, the rule only matches in [pre-match](/configuration/shared/pre-match/) +from auto redirect, and will be skipped in other contexts. + +#### route-options Fields + +See `route-options` fields below. + +### reject + +!!! quote "Changes in sing-box 1.13.0" + + Since sing-box 1.13.0, you can reject (or directly reply to) ICMP echo (ping) requests using `reject` action. + +```json +{ + "action": "reject", + "method": "default", // default + "no_drop": false +} +``` + +`reject` reject connections + +The specified method is used for reject tun connections if `sniff` action has not been performed yet. + +For non-tun connections and already established connections, will just be closed. + +#### method + +For TCP and UDP connections: + +- `default`: Reply with TCP RST for TCP connections, and ICMP port unreachable for UDP packets. +- `drop`: Drop packets. + +For ICMP echo requests: + +- `default`: Reply with ICMP host unreachable. +- `drop`: Drop packets. +- `reply`: Reply with ICMP echo reply. + +#### no_drop + +If not enabled, `method` will be temporarily overwritten to `drop` after 50 triggers in 30s. + +Not available when `method` is set to drop. + +### hijack-dns + +```json +{ + "action": "hijack-dns" +} +``` + +`hijack-dns` hijack DNS requests to the sing-box DNS module. + +## Non-final actions + +### route-options + +```json +{ + "action": "route-options", + "override_address": "", + "override_port": 0, + "network_strategy": "", + "fallback_delay": "", + "udp_disable_domain_unmapping": false, + "udp_connect": false, + "udp_timeout": "", + "tls_fragment": false, + "tls_fragment_fallback_delay": "", + "tls_record_fragment": "" +} +``` + +`route-options` set options for routing. + +#### override_address + +Override the connection destination address. + +#### override_port + +Override the connection destination port. + +#### network_strategy + +See [Dial Fields](/configuration/shared/dial/#network_strategy) for details. + +Only take effect if outbound is direct without `outbound.bind_interface`, +`outbound.inet4_bind_address` and `outbound.inet6_bind_address` set. + +#### network_type + +See [Dial Fields](/configuration/shared/dial/#network_type) for details. + +#### fallback_network_type + +See [Dial Fields](/configuration/shared/dial/#fallback_network_type) for details. + +#### fallback_delay + +See [Dial Fields](/configuration/shared/dial/#fallback_delay) for details. + +#### udp_disable_domain_unmapping + +If enabled, for UDP proxy requests addressed to a domain, +the original packet address will be sent in the response instead of the mapped domain. + +This option is used for compatibility with clients that +do not support receiving UDP packets with domain addresses, such as Surge. + +#### udp_connect + +If enabled, attempts to connect UDP connection to the destination instead of listen. + +#### udp_timeout + +Timeout for UDP connections. + +Setting a larger value than the UDP timeout in inbounds will have no effect. + +Default value for protocol sniffed connections: + +| Timeout | Protocol | +|---------|----------------------| +| `10s` | `dns`, `ntp`, `stun` | +| `30s` | `quic`, `dtls` | + +If no protocol is sniffed, the following ports will be recognized as protocols by default: + +| Port | Protocol | +|------|----------| +| 53 | `dns` | +| 123 | `ntp` | +| 443 | `quic` | +| 3478 | `stun` | + +#### tls_fragment + +!!! question "Since sing-box 1.12.0" + +Fragment TLS handshakes to bypass firewalls. + +This feature is intended to circumvent simple firewalls based on **plaintext packet matching**, +and should not be used to circumvent real censorship. + +Due to poor performance, try `tls_record_fragment` first, and only apply to server names known to be blocked. + +On Linux, Apple platforms, (administrator privileges required) Windows, +the wait time can be automatically detected. Otherwise, it will fall back to +waiting for a fixed time specified by `tls_fragment_fallback_delay`. + +In addition, if the actual wait time is less than 20ms, it will also fall back to waiting for a fixed time, +because the target is considered to be local or behind a transparent proxy. + +#### tls_fragment_fallback_delay + +!!! question "Since sing-box 1.12.0" + +The fallback value used when TLS segmentation cannot automatically determine the wait time. + +`500ms` is used by default. + +#### tls_record_fragment + +!!! question "Since sing-box 1.12.0" + +Fragment TLS handshake into multiple TLS records to bypass firewalls. + +### sniff + +```json +{ + "action": "sniff", + "sniffer": [], + "timeout": "" +} +``` + +`sniff` performs protocol sniffing on connections. + +For deprecated `inbound.sniff` options, it is considered to `sniff()` performed before routing. + +#### sniffer + +Enabled sniffers. + +All sniffers enabled by default. + +Available protocol values an be found on in [Protocol Sniff](../sniff/) + +#### timeout + +Timeout for sniffing. + +`300ms` is used by default. + +### resolve + +```json +{ + "action": "resolve", + "server": "", + "strategy": "", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`resolve` resolve request destination from domain to IP addresses. + +#### server + +Specifies DNS server tag to use instead of selecting through DNS routing. + +#### strategy + +DNS resolution strategy, available values are: `prefer_ipv4`, `prefer_ipv6`, `ipv4_only`, `ipv6_only`. + +`dns.strategy` will be used by default. + +#### disable_cache + +!!! question "Since sing-box 1.12.0" + +Disable cache and save cache in this query. + +#### disable_optimistic_cache + +!!! question "Since sing-box 1.14.0" + +Disable optimistic DNS caching in this query. + +#### rewrite_ttl + +!!! question "Since sing-box 1.12.0" + +Rewrite TTL in DNS responses. + +#### client_subnet + +!!! question "Since sing-box 1.12.0" + +Append a `edns0-subnet` OPT extra record with the specified IP prefix to every query by default. + +If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. + +Will override `dns.client_subnet`. diff --git a/docs/configuration/route/rule_action.zh.md b/docs/configuration/route/rule_action.zh.md new file mode 100644 index 00000000..5b13219b --- /dev/null +++ b/docs/configuration/route/rule_action.zh.md @@ -0,0 +1,319 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [bypass](#bypass) + :material-alert: [reject](#reject) + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [resolve.disable_optimistic_cache](#disable_optimistic_cache) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [tls_fragment](#tls_fragment) + :material-plus: [tls_fragment_fallback_delay](#tls_fragment_fallback_delay) + :material-plus: [tls_record_fragment](#tls_record_fragment) + :material-plus: [resolve.disable_cache](#disable_cache) + :material-plus: [resolve.rewrite_ttl](#rewrite_ttl) + :material-plus: [resolve.client_subnet](#client_subnet) + +## 最终动作 + +### route + +```json +{ + "action": "route", // 默认 + "outbound": "", + + ... // route-options 字段 +} +``` + +`route` 继承了将连接路由到指定出站的经典规则动作。 + +#### outbound + +==必填== + +目标出站的标签。 + +#### route-options 字段 + +参阅下方的 `route-options` 字段。 + +### bypass + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux,且需要启用 `auto_redirect`。 + +```json +{ + "action": "bypass", + "outbound": "", + + ... // route-options 字段 +} +``` + +`bypass` 在预匹配中为 auto redirect 连接在内核层面绕过 sing-box。 + +对于非 auto redirect 连接和已建立的连接,如果指定了 `outbound`,行为与 `route` 相同;否则规则将被跳过。 + +#### outbound + +目标出站的标签。 + +如果未指定,规则仅在来自 auto redirect 的[预匹配](/zh/configuration/shared/pre-match/)中匹配,在其他场景中将被跳过。 + +#### route-options 字段 + +参阅下方的 `route-options` 字段。 + +### reject + +!!! quote "sing-box 1.13.0 中的更改" + + 自 sing-box 1.13.0 起,您可以通过 `reject` 动作拒绝(或直接回复)ICMP 回显(ping)请求。 + +```json +{ + "action": "reject", + "method": "default", // 默认 + "no_drop": false +} +``` + +`reject` 拒绝连接。 + +如果尚未执行 `sniff` 操作,则将使用指定方法拒绝 tun 连接。 + +对于非 tun 连接和已建立的连接,将直接关闭。 + +#### method + +对于 TCP 和 UDP 连接: + +- `default`: 对于 TCP 连接回复 RST,对于 UDP 包回复 ICMP 端口不可达。 +- `drop`: 丢弃数据包。 + +对于 ICMP 回显请求: + +- `default`: 回复 ICMP 主机不可达。 +- `drop`: 丢弃数据包。 +- `reply`: 回复以 ICMP 回显应答。 + +#### no_drop + +如果未启用,则 30 秒内触发 50 次后,`method` 将被暂时覆盖为 `drop`。 + +当 `method` 设为 `drop` 时不可用。 + +### hijack-dns + +```json +{ + "action": "hijack-dns" +} +``` + +`hijack-dns` 劫持 DNS 请求至 sing-box DNS 模块。 + +## 非最终动作 + +### route-options + +```json +{ + "action": "route-options", + "override_address": "", + "override_port": 0, + "network_strategy": "", + "fallback_delay": "", + "udp_disable_domain_unmapping": false, + "udp_connect": false, + "udp_timeout": "" +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +`route-options` 为路由设置选项。 + +#### override_address + +覆盖目标地址。 + +#### override_port + +覆盖目标端口。 + +#### network_strategy + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#network_strategy)。 + +仅当出站为 `direct` 且 `outbound.bind_interface`, `outbound.inet4_bind_address` +且 `outbound.inet6_bind_address` 未设置时生效。 + +#### network_type + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#network_type)。 + +#### fallback_network_type + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#fallback_network_type)。 + +#### fallback_delay + +详情参阅 [拨号字段](/zh/configuration/shared/dial/#fallback_delay)。 + +#### udp_disable_domain_unmapping + +如果启用,对于地址为域的 UDP 代理请求,将在响应中发送原始包地址而不是映射的域。 + +此选项用于兼容不支持接收带有域地址的 UDP 包的客户端,如 Surge。 + +#### udp_connect + +如果启用,将尝试将 UDP 连接 connect 到目标而不是 listen。 + +#### udp_timeout + +UDP 连接超时时间。 + +设置比入站 UDP 超时更大的值将无效。 + +已探测协议连接的默认值: + +| 超时 | 协议 | +|-------|----------------------| +| `10s` | `dns`, `ntp`, `stun` | +| `30s` | `quic`, `dtls` | + +如果没有探测到协议,以下端口将默认识别为协议: + +| 端口 | 协议 | +|------|--------| +| 53 | `dns` | +| 123 | `ntp` | +| 443 | `quic` | +| 3478 | `stun` | + +#### tls_fragment + +!!! question "自 sing-box 1.12.0 起" + +通过分段 TLS 握手数据包来绕过防火墙检测。 + +此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真的审查。 + +由于性能不佳,请首先尝试 `tls_record_fragment`,且仅应用于已知被阻止的服务器名称。 + +在 Linux、Apple 平台和需要管理员权限的 Windows 系统上,可自动检测等待时间。 +若无法自动检测,将回退使用 `tls_fragment_fallback_delay` 指定的固定等待时间。 + +此外,若实际等待时间小于 20 毫秒,同样会回退至固定等待时间模式,因为此时判定目标处于本地或透明代理之后。 + +#### tls_fragment_fallback_delay + +!!! question "自 sing-box 1.12.0 起" + +当 TLS 分片功能无法自动判定等待时间时使用的回退值。 + +默认使用 `500ms`。 + +#### tls_record_fragment + +!!! question "自 sing-box 1.12.0 起" + +通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。 + +### sniff + +```json +{ + "action": "sniff", + "sniffer": [], + "timeout": "" +} +``` + +`sniff` 对连接执行协议嗅探。 + +对于已弃用的 `inbound.sniff` 选项,被视为在路由之前执行的 `sniff`。 + +#### sniffer + +启用的探测器。 + +默认启用所有探测器。 + +可用的协议值可以在 [协议嗅探](../sniff/) 中找到。 + +#### timeout + +探测超时时间。 + +默认使用 300ms。 + +### resolve + +```json +{ + "action": "resolve", + "server": "", + "strategy": "", + "disable_cache": false, + "disable_optimistic_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`resolve` 将请求的目标从域名解析为 IP 地址。 + +#### server + +指定要使用的 DNS 服务器的标签,而不是通过 DNS 路由进行选择。 + +#### strategy + +DNS 解析策略,可用值有:`prefer_ipv4`、`prefer_ipv6`、`ipv4_only`、`ipv6_only`。 + +默认使用 `dns.strategy`。 + +#### disable_cache + +!!! question "自 sing-box 1.12.0 起" + +在此查询中禁用缓存。 + +#### disable_optimistic_cache + +!!! question "自 sing-box 1.14.0 起" + +在此查询中禁用乐观 DNS 缓存。 + +#### rewrite_ttl + +!!! question "自 sing-box 1.12.0 起" + +重写 DNS 回应中的 TTL。 + +#### client_subnet + +!!! question "自 sing-box 1.12.0 起" + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +将覆盖 `dns.client_subnet`. diff --git a/docs/configuration/route/sniff.md b/docs/configuration/route/sniff.md new file mode 100644 index 00000000..0fb386f7 --- /dev/null +++ b/docs/configuration/route/sniff.md @@ -0,0 +1,32 @@ +!!! quote "Changes in sing-box 1.10.0" + + :material-plus: QUIC client type detect support for QUIC + :material-plus: Chromium support for QUIC + :material-plus: BitTorrent support + :material-plus: DTLS support + :material-plus: SSH support + :material-plus: RDP support + +If enabled in the inbound, the protocol and domain name (if present) of by the connection can be sniffed. + +#### Supported Protocols + +| Network | Protocol | Domain Name | Client | +|:-------:|:------------:|:-----------:|:----------------:| +| TCP | `http` | Host | / | +| TCP | `tls` | Server Name | / | +| UDP | `quic` | Server Name | QUIC Client Type | +| UDP | `stun` | / | / | +| TCP/UDP | `dns` | / | / | +| TCP/UDP | `bittorrent` | / | / | +| UDP | `dtls` | / | / | +| TCP | `ssh` | / | SSH Client Name | +| TCP | `rdp` | / | / | +| UDP | `ntp` | / | / | + +| QUIC Client | Type | +|:------------------------:|:----------:| +| Chromium/Cronet | `chromium` | +| Safari/Apple Network API | `safari` | +| Firefox / uquic firefox | `firefox` | +| quic-go / uquic chrome | `quic-go` | diff --git a/docs/configuration/route/sniff.zh.md b/docs/configuration/route/sniff.zh.md new file mode 100644 index 00000000..8cb9488f --- /dev/null +++ b/docs/configuration/route/sniff.zh.md @@ -0,0 +1,32 @@ +!!! quote "sing-box 1.10.0 中的更改" + + :material-plus: QUIC 的 客户端类型探测支持 + :material-plus: QUIC 的 Chromium 支持 + :material-plus: BitTorrent 支持 + :material-plus: DTLS 支持 + :material-plus: SSH 支持 + :material-plus: RDP 支持 + +如果在入站中启用,则可以嗅探连接的协议和域名(如果存在)。 + +#### 支持的协议 + +| 网络 | 协议 | 域名 | 客户端 | +|:-------:|:------------:|:-----------:|:----------:| +| TCP | `http` | Host | / | +| TCP | `tls` | Server Name | / | +| UDP | `quic` | Server Name | QUIC 客户端类型 | +| UDP | `stun` | / | / | +| TCP/UDP | `dns` | / | / | +| TCP/UDP | `bittorrent` | / | / | +| UDP | `dtls` | / | / | +| TCP | `ssh` | / | SSH 客户端名称 | +| TCP | `rdp` | / | / | +| UDP | `ntp` | / | / | + +| QUIC 客户端 | 类型 | +|:------------------------:|:----------:| +| Chromium/Cronet | `chromium` | +| Safari/Apple Network API | `safari` | +| Firefox / uquic firefox | `firefox` | +| quic-go / uquic chrome | `quic-go` | diff --git a/docs/configuration/rule-set/adguard.md b/docs/configuration/rule-set/adguard.md new file mode 100644 index 00000000..c8bd32fa --- /dev/null +++ b/docs/configuration/rule-set/adguard.md @@ -0,0 +1,65 @@ +!!! question "Since sing-box 1.10.0" + +sing-box supports some rule-set formats from other projects which cannot be fully translated to sing-box, +currently only AdGuard DNS Filter. + +These formats are not directly supported as source formats, +instead you need to convert them to binary rule-set. + +## Convert + +Use `sing-box rule-set convert --type adguard [--output .srs] .txt` to convert to binary rule-set. + +## Performance + +AdGuard keeps all rules in memory and matches them sequentially, +while sing-box chooses high performance and smaller memory usage. +As a trade-off, you cannot know which rule item is matched. + +## Compatibility + +Almost all rules in [AdGuardSDNSFilter](https://github.com/AdguardTeam/AdGuardSDNSFilter) +and rules in rule-sets listed in [adguard-filter-list](https://github.com/ppfeufer/adguard-filter-list) +are supported. + +## Supported formats + +### AdGuard Filter + +#### Basic rule syntax + +| Syntax | Supported | +|--------|------------------| +| `@@` | :material-check: | +| `\|\|` | :material-check: | +| `\|` | :material-check: | +| `^` | :material-check: | +| `*` | :material-check: | + +#### Host syntax + +| Syntax | Example | Supported | +|-------------|--------------------------|--------------------------| +| Scheme | `https://` | :material-alert: Ignored | +| Domain Host | `example.org` | :material-check: | +| IP Host | `1.1.1.1`, `10.0.0.` | :material-close: | +| Regexp | `/regexp/` | :material-check: | +| Port | `example.org:80` | :material-close: | +| Path | `example.org/path/ad.js` | :material-close: | + +#### Modifier syntax + +| Modifier | Supported | +|-----------------------|--------------------------| +| `$important` | :material-check: | +| `$dnsrewrite=0.0.0.0` | :material-alert: Ignored | +| Any other modifiers | :material-close: | + +### Hosts + +Only items with `0.0.0.0` IP addresses will be accepted. + +### Simple + +When all rule lines are valid domains, they are treated as simple line-by-line domain rules which, +like hosts, only match the exact same domain. \ No newline at end of file diff --git a/docs/configuration/rule-set/adguard.zh.md b/docs/configuration/rule-set/adguard.zh.md new file mode 100644 index 00000000..82773280 --- /dev/null +++ b/docs/configuration/rule-set/adguard.zh.md @@ -0,0 +1,64 @@ +!!! question "自 sing-box 1.10.0 起" + +sing-box 支持其他项目的一些规则集格式,这些格式无法完全转换为 sing-box, +目前只有 AdGuard DNS Filter。 + +这些格式不直接作为源格式支持, +而是需要将它们转换为二进制规则集。 + +## 转换 + +使用 `sing-box rule-set convert --type adguard [--output .srs] .txt` 以转换为二进制规则集。 + +## 性能 + +AdGuard 将所有规则保存在内存中并按顺序匹配, +而 sing-box 选择高性能和较小的内存使用量。 +作为权衡,您无法知道匹配了哪个规则项。 + +## 兼容性 + +[AdGuardSDNSFilter](https://github.com/AdguardTeam/AdGuardSDNSFilter) +中的几乎所有规则以及 [adguard-filter-list](https://github.com/ppfeufer/adguard-filter-list) +中列出的规则集中的规则均受支持。 + +## 支持的格式 + +### AdGuard Filter + +#### 基本规则语法 + +| 语法 | 支持 | +|--------|------------------| +| `@@` | :material-check: | +| `\|\|` | :material-check: | +| `\|` | :material-check: | +| `^` | :material-check: | +| `*` | :material-check: | + +#### 主机语法 + +| 语法 | 示例 | 支持 | +|-------------|--------------------------|--------------------------| +| Scheme | `https://` | :material-alert: Ignored | +| Domain Host | `example.org` | :material-check: | +| IP Host | `1.1.1.1`, `10.0.0.` | :material-close: | +| Regexp | `/regexp/` | :material-check: | +| Port | `example.org:80` | :material-close: | +| Path | `example.org/path/ad.js` | :material-close: | + +#### 描述符语法 + +| 描述符 | 支持 | +|-----------------------|--------------------------| +| `$important` | :material-check: | +| `$dnsrewrite=0.0.0.0` | :material-alert: Ignored | +| 任何其他描述符 | :material-close: | + +### Hosts + +只有 IP 地址为 `0.0.0.0` 的条目将被接受。 + +### 简易 + +当所有行都是有效域时,它们被视为简单的逐行域规则, 与 hosts 一样,只匹配完全相同的域。 \ No newline at end of file diff --git a/docs/configuration/rule-set/headless-rule.md b/docs/configuration/rule-set/headless-rule.md new file mode 100644 index 00000000..81a5e9a0 --- /dev/null +++ b/docs/configuration/rule-set/headless-rule.md @@ -0,0 +1,325 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [package_name_regex](#package_name_regex) + :material-alert: [query_type](#query_type) + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [network_interface_address](#network_interface_address) + :material-plus: [default_interface_address](#default_interface_address) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [network_type](#network_type) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) + +### Structure + +!!! question "Since sing-box 1.8.0" + +```json +{ + "rules": [ + { + "query_type": [ + "A", + "HTTPS", + 32768 + ], + "network": [ + "tcp" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "source_ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "process_path_regex": [ + "^/usr/bin/.+" + ], + "package_name": [ + "com.termux" + ], + "package_name_regex": [ + "^com\\.termux.*" + ], + "network_type": [ + "wifi" + ], + "network_is_expensive": false, + "network_is_constrained": false, + "network_interface_address": { + "wifi": [ + "2000::/3" + ] + }, + "default_interface_address": [ + "2000::/3" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "invert": false + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "invert": false + } + ] +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Default Fields + +!!! note "" + + The default rule uses the following matching logic: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `ip_cidr`) && + (`port` || `port_range`) && + (`source_port` || `source_port_range`) && + `other fields` + +#### query_type + +!!! quote "Changes in sing-box 1.14.0" + + When a DNS rule references this rule-set, this field now also applies + when the DNS rule is matched from an internal domain resolution that + does not target a specific DNS server. In earlier versions, only DNS + queries received from a client evaluated this field. See + [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules) + for the full list. + + When a DNS rule references a rule-set containing this field, the DNS + rule is incompatible in the same DNS configuration with Legacy Address + Filter Fields in DNS rules, the Legacy `strategy` DNS rule action + option, and the Legacy `rule_set_ip_cidr_accept_empty` DNS rule item. + +DNS query type. Values can be integers or type name strings. + +#### network + +`tcp` or `udp`. + +#### domain + +Match full domain. + +#### domain_suffix + +Match domain suffix. + +#### domain_keyword + +Match domain using keyword. + +#### domain_regex + +Match domain using regular expression. + +#### source_ip_cidr + +Match source IP CIDR. + +#### ip_cidr + +!!! info "" + + `ip_cidr` is an alias for `source_ip_cidr` when `rule_set_ipcidr_match_source` enabled in route/DNS rules. + +Match IP CIDR. + +#### source_port + +Match source port. + +#### source_port_range + +Match source port range. + +#### port + +Match port. + +#### port_range + +Match port range. + +#### process_name + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process name. + +#### process_path + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path. + +#### process_path_regex + +!!! question "Since sing-box 1.10.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path using regular expression. + +#### package_name + +Match android package name. + +#### package_name_regex + +!!! question "Since sing-box 1.14.0" + +Match android package name using regular expression. + +#### network_type + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match network type. + +Available values: `wifi`, `cellular`, `ethernet` and `other`. + +#### network_is_expensive + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match if network is considered Metered (on Android) or considered expensive, +such as Cellular or a Personal Hotspot (on Apple platforms). + +#### network_is_constrained + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Apple platforms. + +Match if network is in Low Data Mode. + +#### network_interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Matches network interface (same values as `network_type`) address. + +#### default_interface_address + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match default interface address. + +#### wifi_ssid + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match WiFi SSID. + +#### wifi_bssid + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms. + +Match WiFi BSSID. + +#### invert + +Invert match result. + +### Logical Fields + +#### type + +`logical` + +#### mode + +==Required== + +`and` or `or` + +#### rules + +==Required== + +Included rules. diff --git a/docs/configuration/rule-set/headless-rule.zh.md b/docs/configuration/rule-set/headless-rule.zh.md new file mode 100644 index 00000000..ad78ffe4 --- /dev/null +++ b/docs/configuration/rule-set/headless-rule.zh.md @@ -0,0 +1,316 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [package_name_regex](#package_name_regex) + :material-alert: [query_type](#query_type) + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [network_interface_address](#network_interface_address) + :material-plus: [default_interface_address](#default_interface_address) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [network_type](#network_type) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) + +### 结构 + +!!! question "自 sing-box 1.8.0 起" + +```json +{ + "rules": [ + { + "query_type": [ + "A", + "HTTPS", + 32768 + ], + "network": [ + "tcp" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "source_ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "process_path_regex": [ + "^/usr/bin/.+" + ], + "package_name": [ + "com.termux" + ], + "package_name_regex": [ + "^com\\.termux.*" + ], + "network_type": [ + "wifi" + ], + "network_is_expensive": false, + "network_is_constrained": false, + "network_interface_address": { + "wifi": [ + "2000::/3" + ] + }, + "default_interface_address": [ + "2000::/3" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "invert": false + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "invert": false + } + ] +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### Default Fields + +!!! note "" + + 默认规则使用以下匹配逻辑: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `ip_cidr`) && + (`port` || `port_range`) && + (`source_port` || `source_port_range`) && + `other fields` + +#### query_type + +!!! quote "sing-box 1.14.0 中的更改" + + 当 DNS 规则引用此规则集时,此字段现在也会在 DNS 规则被未指定具体 + DNS 服务器的内部域名解析匹配时生效。此前只有来自客户端的 DNS 查询 + 才会评估此字段。完整列表参阅 + [迁移指南](/zh/migration/#dns-规则中的-ip_version-和-query_type-行为更改)。 + + 当 DNS 规则引用了包含此字段的规则集时,该 DNS 规则在同一 DNS 配置中 + 不能与旧版地址筛选字段 (DNS 规则)、旧版 DNS 规则动作 `strategy` 选项, + 或旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项共存。 + +DNS 查询类型。值可以为整数或者类型名称字符串。 + +#### network + +`tcp` 或 `udp`。 + +#### domain + +匹配完整域名。 + +#### domain_suffix + +匹配域名后缀。 + +#### domain_keyword + +匹配域名关键字。 + +#### domain_regex + +匹配域名正则表达式。 + +#### source_ip_cidr + +匹配源 IP CIDR。 + +#### ip_cidr + +匹配 IP CIDR。 + +#### source_port + +匹配源端口。 + +#### source_port_range + +匹配源端口范围。 + +#### port + +匹配端口。 + +#### port_range + +匹配端口范围。 + +#### process_name + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +匹配进程名称。 + +#### process_path + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配进程路径。 + +#### process_path_regex + +!!! question "自 sing-box 1.10.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +使用正则表达式匹配进程路径。 + +#### package_name + +匹配 Android 应用包名。 + +#### package_name_regex + +!!! question "自 sing-box 1.14.0 起" + +使用正则表达式匹配 Android 应用包名。 + +#### network_type + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配网络类型。 + +Available values: `wifi`, `cellular`, `ethernet` and `other`. + +#### network_is_expensive + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配如果网络被视为计费 (在 Android) 或被视为昂贵, +像蜂窝网络或个人热点 (在 Apple 平台)。 + +#### network_is_constrained + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Apple 平台图形客户端中支持。 + +匹配如果网络在低数据模式下。 + +#### network_interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配网络接口(可用值同 `network_type`)地址。 + +#### default_interface_address + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS. + +匹配默认接口地址。 + +#### wifi_ssid + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +匹配 WiFi SSID。 + +#### wifi_bssid + +!!! quote "" + + 仅在 Android 与 Apple 平台图形客户端中支持。 + +#### invert + +反选匹配结果。 + +### 逻辑字段 + +#### type + +`logical` + +#### mode + +==必填== + +`and` 或 `or` + +#### rules + +==必填== + +包括的规则。 \ No newline at end of file diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md new file mode 100644 index 00000000..73ec7b85 --- /dev/null +++ b/docs/configuration/rule-set/index.md @@ -0,0 +1,115 @@ +!!! quote "Changes in sing-box 1.10.0" + + :material-plus: `type: inline` + +# rule-set + +!!! question "Since sing-box 1.8.0" + +### Structure + +=== "Inline" + + !!! question "Since sing-box 1.10.0" + + ```json + { + "type": "inline", // optional + "tag": "", + "rules": [] + } + ``` + +=== "Local File" + + ```json + { + "type": "local", + "tag": "", + "format": "source", // or binary + "path": "" + } + ``` + +=== "Remote File" + + !!! info "" + + Remote rule-set will be cached if `experimental.cache_file.enabled`. + + ```json + { + "type": "remote", + "tag": "", + "format": "source", // or binary + "url": "", + "download_detour": "", // optional + "update_interval": "" // optional + } + ``` + +### Fields + +#### type + +==Required== + +Type of rule-set, `local` or `remote`. + +#### tag + +==Required== + +Tag of rule-set. + +### Inline Fields + +!!! question "Since sing-box 1.10.0" + +#### rules + +==Required== + +List of [Headless Rule](./headless-rule/). + +### Local or Remote Fields + +#### format + +==Required== + +Format of rule-set file, `source` or `binary`. + +Optional when `path` or `url` uses `json` or `srs` as extension. + +### Local Fields + +#### path + +==Required== + +!!! note "" + + Will be automatically reloaded if file modified since sing-box 1.10.0. + +File path of rule-set. + +### Remote Fields + +#### url + +==Required== + +Download URL of rule-set. + +#### download_detour + +Tag of the outbound to download rule-set. + +Default outbound will be used if empty. + +#### update_interval + +Update interval of rule-set. + +`1d` will be used if empty. diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md new file mode 100644 index 00000000..eac51953 --- /dev/null +++ b/docs/configuration/rule-set/index.zh.md @@ -0,0 +1,115 @@ +!!! quote "sing-box 1.10.0 中的更改" + + :material-plus: `type: inline` + +# 规则集 + +!!! question "自 sing-box 1.8.0 起" + +### 结构 + +=== "内联" + + !!! question "自 sing-box 1.10.0 起" + + ```json + { + "type": "inline", // 可选 + "tag": "", + "rules": [] + } + ``` + +=== "本地文件" + + ```json + { + "type": "local", + "tag": "", + "format": "source", // or binary + "path": "" + } + ``` + +=== "远程文件" + + !!! info "" + + 远程规则集将被缓存如果 `experimental.cache_file.enabled` 已启用。 + + ```json + { + "type": "remote", + "tag": "", + "format": "source", // or binary + "url": "", + "download_detour": "", // 可选 + "update_interval": "" // 可选 + } + ``` + +### 字段 + +#### type + +==必填== + +规则集类型, `local` 或 `remote`。 + +#### tag + +==必填== + +规则集的标签。 + +### 内联字段 + +!!! question "自 sing-box 1.10.0 起" + +#### rules + +==必填== + +一组 [无头规则](./headless-rule/). + +### 本地或远程字段 + +#### format + +==必填== + +规则集格式, `source` 或 `binary`。 + +当 `path` 或 `url` 使用 `json` 或 `srs` 作为扩展名时可选。 + +### 本地字段 + +#### path + +==必填== + +!!! note "" + + 自 sing-box 1.10.0 起,文件更改时将自动重新加载。 + +规则集的文件路径。 + +### 远程字段 + +#### url + +==必填== + +规则集的下载 URL。 + +#### download_detour + +用于下载规则集的出站的标签。 + +如果为空,将使用默认出站。 + +#### update_interval + +规则集的更新间隔。 + +默认使用 `1d`。 diff --git a/docs/configuration/rule-set/source-format.md b/docs/configuration/rule-set/source-format.md new file mode 100644 index 00000000..47e0e245 --- /dev/null +++ b/docs/configuration/rule-set/source-format.md @@ -0,0 +1,54 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: version `5` + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: version `4` + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: version `3` + +!!! quote "Changes in sing-box 1.10.0" + + :material-plus: version `2` + +!!! question "Since sing-box 1.8.0" + +### Structure + +```json +{ + "version": 3, + "rules": [] +} +``` + +### Compile + +Use `sing-box rule-set compile [--output .srs] .json` to compile source to binary rule-set. + +### Fields + +#### version + +==Required== + +Version of rule-set. + +* 1: sing-box 1.8.0: Initial rule-set version. +* 2: sing-box 1.10.0: Optimized memory usages of `domain_suffix` rules in binary rule-sets. +* 3: sing-box 1.11.0: Added `network_type`, `network_is_expensive` and `network_is_constrainted` rule items. +* 4: sing-box 1.13.0: Added `network_interface_address` and `default_interface_address` rule items. +* 5: sing-box 1.14.0: Added `package_name_regex` rule item. + +#### rules + +==Required== + +List of [Headless Rule](../headless-rule/). diff --git a/docs/configuration/rule-set/source-format.zh.md b/docs/configuration/rule-set/source-format.zh.md new file mode 100644 index 00000000..3f710864 --- /dev/null +++ b/docs/configuration/rule-set/source-format.zh.md @@ -0,0 +1,54 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: version `5` + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: version `4` + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: version `3` + +!!! quote "sing-box 1.10.0 中的更改" + + :material-plus: version `2` + +!!! question "自 sing-box 1.8.0 起" + +### 结构 + +```json +{ + "version": 3, + "rules": [] +} +``` + +### 编译 + +使用 `sing-box rule-set compile [--output .srs] .json` 以编译源文件为二进制规则集。 + +### 字段 + +#### version + +==必填== + +规则集版本。 + +* 1: sing-box 1.8.0: 初始规则集版本。 +* 2: sing-box 1.10.0: 优化了二进制规则集中 `domain_suffix` 规则的内存使用。 +* 3: sing-box 1.11.0: 添加了 `network_type`、 `network_is_expensive` 和 `network_is_constrainted` 规则项。 +* 4: sing-box 1.13.0: 添加了 `network_interface_address` 和 `default_interface_address` 规则项。 +* 5: sing-box 1.14.0: 添加了 `package_name_regex` 规则项。 + +#### rules + +==必填== + +一组 [无头规则](../headless-rule/). diff --git a/docs/configuration/service/ccm.md b/docs/configuration/service/ccm.md new file mode 100644 index 00000000..337cacb1 --- /dev/null +++ b/docs/configuration/service/ccm.md @@ -0,0 +1,131 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.13.0" + +# CCM + +CCM (Claude Code Multiplexer) service is a multiplexing service that allows you to access your local Claude Code subscription remotely through custom tokens. + +It handles OAuth authentication with Claude's API on your local machine while allowing remote Claude Code to authenticate using Auth Tokens via the `ANTHROPIC_AUTH_TOKEN` environment variable. + +### Structure + +```json +{ + "type": "ccm", + + ... // Listen Fields + + "credential_path": "", + "usages_path": "", + "users": [], + "headers": {}, + "detour": "", + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### credential_path + +Path to the Claude Code OAuth credentials file. + +If not specified, defaults to: +- `$CLAUDE_CONFIG_DIR/.credentials.json` if `CLAUDE_CONFIG_DIR` environment variable is set +- `~/.claude/.credentials.json` otherwise + +On macOS, credentials are read from the system keychain first, then fall back to the file if unavailable. + +Refreshed tokens are automatically written back to the same location. + +#### usages_path + +Path to the file for storing aggregated API usage statistics. + +Usage tracking is disabled if not specified. + +When enabled, the service tracks and saves comprehensive statistics including: +- Request counts +- Token usage (input, output, cache read, cache creation) +- Calculated costs in USD based on Claude API pricing + +Statistics are organized by model, context window (200k standard vs 1M premium), and optionally by user when authentication is enabled. + +The statistics file is automatically saved every minute and upon service shutdown. + +#### users + +List of authorized users for token authentication. + +If empty, no authentication is required. + +Object format: + +```json +{ + "name": "", + "token": "" +} +``` + +Object fields: + +- `name`: Username identifier for tracking purposes. +- `token`: Bearer token for authentication. Claude Code authenticates by setting the `ANTHROPIC_AUTH_TOKEN` environment variable to their token value. + +#### headers + +Custom HTTP headers to send to the Claude API. + +These headers will override any existing headers with the same name. + +#### detour + +Outbound tag for connecting to the Claude API. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +### Example + +#### Server + +```json +{ + "services": [ + { + "type": "ccm", + "listen": "0.0.0.0", + "listen_port": 8080, + "usages_path": "./claude-usages.json", + "users": [ + { + "name": "alice", + "token": "ak-ccm-hello-world" + }, + { + "name": "bob", + "token": "ak-ccm-hello-bob" + } + ] + } + ] +} +``` + +#### Client + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:8080" +export ANTHROPIC_AUTH_TOKEN="ak-ccm-hello-world" + +claude +``` diff --git a/docs/configuration/service/ccm.zh.md b/docs/configuration/service/ccm.zh.md new file mode 100644 index 00000000..f6490b5e --- /dev/null +++ b/docs/configuration/service/ccm.zh.md @@ -0,0 +1,131 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.13.0 起" + +# CCM + +CCM(Claude Code 多路复用器)服务是一个多路复用服务,允许您通过自定义令牌远程访问本地的 Claude Code 订阅。 + +它在本地机器上处理与 Claude API 的 OAuth 身份验证,同时允许远程 Claude Code 通过 `ANTHROPIC_AUTH_TOKEN` 环境变量使用认证令牌进行身份验证。 + +### 结构 + +```json +{ + "type": "ccm", + + ... // 监听字段 + + "credential_path": "", + "usages_path": "", + "users": [], + "headers": {}, + "detour": "", + "tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 + +### 字段 + +#### credential_path + +Claude Code OAuth 凭据文件的路径。 + +如果未指定,默认值为: +- 如果设置了 `CLAUDE_CONFIG_DIR` 环境变量,则使用 `$CLAUDE_CONFIG_DIR/.credentials.json` +- 否则使用 `~/.claude/.credentials.json` + +在 macOS 上,首先从系统钥匙串读取凭据,如果不可用则回退到文件。 + +刷新的令牌会自动写回相同位置。 + +#### usages_path + +用于存储聚合 API 使用统计信息的文件路径。 + +如果未指定,使用跟踪将被禁用。 + +启用后,服务会跟踪并保存全面的统计信息,包括: +- 请求计数 +- 令牌使用量(输入、输出、缓存读取、缓存创建) +- 基于 Claude API 定价计算的美元成本 + +统计信息按模型、上下文窗口(200k 标准版 vs 1M 高级版)以及可选的用户(启用身份验证时)进行组织。 + +统计文件每分钟自动保存一次,并在服务关闭时保存。 + +#### users + +用于令牌身份验证的授权用户列表。 + +如果为空,则不需要身份验证。 + +对象格式: + +```json +{ + "name": "", + "token": "" +} +``` + +对象字段: + +- `name`:用于跟踪的用户名标识符。 +- `token`:用于身份验证的 Bearer 令牌。Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进行身份验证。 + +#### headers + +发送到 Claude API 的自定义 HTTP 头。 + +这些头会覆盖同名的现有头。 + +#### detour + +用于连接 Claude API 的出站标签。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +### 示例 + +#### 服务端 + +```json +{ + "services": [ + { + "type": "ccm", + "listen": "0.0.0.0", + "listen_port": 8080, + "usages_path": "./claude-usages.json", + "users": [ + { + "name": "alice", + "token": "ak-ccm-hello-world" + }, + { + "name": "bob", + "token": "ak-ccm-hello-bob" + } + ] + } + ] +} +``` + +#### 客户端 + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:8080" +export ANTHROPIC_AUTH_TOKEN="ak-ccm-hello-world" + +claude +``` diff --git a/docs/configuration/service/derp.md b/docs/configuration/service/derp.md new file mode 100644 index 00000000..3d7443a3 --- /dev/null +++ b/docs/configuration/service/derp.md @@ -0,0 +1,135 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# DERP + +DERP service is a Tailscale DERP server, similar to [derper](https://pkg.go.dev/tailscale.com/cmd/derper). + +### Structure + +```json +{ + "type": "derp", + + ... // Listen Fields + + "tls": {}, + "config_path": "", + "verify_client_endpoint": [], + "verify_client_url": [], + "home": "", + "mesh_with": [], + "mesh_psk": "", + "mesh_psk_file": "", + "stun": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### config_path + +==Required== + +Derper configuration file path. + +Example: `derper.key` + +#### verify_client_endpoint + +Tailscale endpoints tags to verify clients. + +#### verify_client_url + +URL to verify clients. + +Object format: + +```json +{ + "url": "https://my-headscale.com/verify", + + ... // Dial Fields +} +``` + +Setting Array value to a string `__URL__` is equivalent to configuring: + +```json +{ "url": __URL__ } +``` + +#### home + +What to serve at the root path. It may be left empty (the default, for a default homepage), `blank` for a blank page, or a URL to redirect to + +#### mesh_with + +Mesh with other DERP servers. + +Object format: + +```json +{ + "server": "", + "server_port": "", + "host": "", + "tls": {}, + + ... // Dial Fields +} +``` + +Object fields: + +- `server`: **Required** DERP server address. +- `server_port`: **Required** DERP server port. +- `host`: Custom DERP hostname. +- `tls`: [TLS](/configuration/shared/tls/#outbound) +- `Dial Fields`: [Dial Fields](/configuration/shared/dial/) + +#### mesh_psk + +Pre-shared key for DERP mesh. + +#### mesh_psk_file + +Pre-shared key file for DERP mesh. + +#### stun + +STUN server listen options. + +Object format: + +```json +{ + "enabled": true, + + ... // Listen Fields +} +``` + +Object fields: + +- `enabled`: **Required** Enable STUN server. +- `listen`: **Required** STUN server listen address, default to `::`. +- `listen_port`: **Required** STUN server listen port, default to `3478`. +- `other Listen Fields`: [Listen Fields](/configuration/shared/listen/) + +Setting `stun` value to a number `__PORT__` is equivalent to configuring: + +```json +{ "enabled": true, "listen_port": __PORT__ } +``` diff --git a/docs/configuration/service/derp.zh.md b/docs/configuration/service/derp.zh.md new file mode 100644 index 00000000..b22ff413 --- /dev/null +++ b/docs/configuration/service/derp.zh.md @@ -0,0 +1,135 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# DERP + +DERP 服务是一个 Tailscale DERP 服务器,类似于 [derper](https://pkg.go.dev/tailscale.com/cmd/derper)。 + +### 结构 + +```json +{ + "type": "derp", + + ... // 监听字段 + + "tls": {}, + "config_path": "", + "verify_client_endpoint": [], + "verify_client_url": [], + "home": "", + "mesh_with": [], + "mesh_psk": "", + "mesh_psk_file": "", + "stun": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 + +### 字段 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +#### config_path + +==必填== + +Derper 配置文件路径。 + +示例:`derper.key` + +#### verify_client_endpoint + +用于验证客户端的 Tailscale 端点标签。 + +#### verify_client_url + +用于验证客户端的 URL。 + +对象格式: + +```json +{ + "url": "https://my-headscale.com/verify", + + ... // 拨号字段 +} +``` + +将数组值设置为字符串 `__URL__` 等同于配置: + +```json +{ "url": __URL__ } +``` + +#### home + +在根路径提供的内容。可以留空(默认值,显示默认主页)、`blank` 显示空白页面,或一个重定向的 URL。 + +#### mesh_with + +与其他 DERP 服务器组网。 + +对象格式: + +```json +{ + "server": "", + "server_port": "", + "host": "", + "tls": {}, + + ... // 拨号字段 +} +``` + +对象字段: + +- `server`:**必填** DERP 服务器地址。 +- `server_port`:**必填** DERP 服务器端口。 +- `host`:自定义 DERP 主机名。 +- `tls`:[TLS](/zh/configuration/shared/tls/#出站) +- `拨号字段`:[拨号字段](/zh/configuration/shared/dial/) + +#### mesh_psk + +DERP 组网的预共享密钥。 + +#### mesh_psk_file + +DERP 组网的预共享密钥文件。 + +#### stun + +STUN 服务器监听选项。 + +对象格式: + +```json +{ + "enabled": true, + + ... // 监听字段 +} +``` + +对象字段: + +- `enabled`:**必填** 启用 STUN 服务器。 +- `listen`:**必填** STUN 服务器监听地址,默认为 `::`。 +- `listen_port`:**必填** STUN 服务器监听端口,默认为 `3478`。 +- `其他监听字段`:[监听字段](/zh/configuration/shared/listen/) + +将 `stun` 值设置为数字 `__PORT__` 等同于配置: + +```json +{ "enabled": true, "listen_port": __PORT__ } +``` \ No newline at end of file diff --git a/docs/configuration/service/index.md b/docs/configuration/service/index.md new file mode 100644 index 00000000..de3583b2 --- /dev/null +++ b/docs/configuration/service/index.md @@ -0,0 +1,34 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# Service + +### Structure + +```json +{ + "services": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### Fields + +| Type | Format | +|------------|------------------------| +| `ccm` | [CCM](./ccm) | +| `derp` | [DERP](./derp) | +| `ocm` | [OCM](./ocm) | +| `resolved` | [Resolved](./resolved) | +| `ssm-api` | [SSM API](./ssm-api) | + +#### tag + +The tag of the endpoint. diff --git a/docs/configuration/service/index.zh.md b/docs/configuration/service/index.zh.md new file mode 100644 index 00000000..a0d18cbb --- /dev/null +++ b/docs/configuration/service/index.zh.md @@ -0,0 +1,34 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# 服务 + +### 结构 + +```json +{ + "services": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### 字段 + +| 类型 | 格式 | +|-----------|------------------------| +| `ccm` | [CCM](./ccm) | +| `derp` | [DERP](./derp) | +| `ocm` | [OCM](./ocm) | +| `resolved`| [Resolved](./resolved) | +| `ssm-api` | [SSM API](./ssm-api) | + +#### tag + +端点的标签。 \ No newline at end of file diff --git a/docs/configuration/service/ocm.md b/docs/configuration/service/ocm.md new file mode 100644 index 00000000..5fdf2b6b --- /dev/null +++ b/docs/configuration/service/ocm.md @@ -0,0 +1,185 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.13.0" + +# OCM + +OCM (OpenAI Codex Multiplexer) service is a multiplexing service that allows you to access your local OpenAI Codex subscription remotely through custom tokens. + +It handles OAuth authentication with OpenAI's API on your local machine while allowing remote clients to authenticate using custom tokens. + +### Structure + +```json +{ + "type": "ocm", + + ... // Listen Fields + + "credential_path": "", + "usages_path": "", + "users": [], + "headers": {}, + "detour": "", + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### credential_path + +Path to the OpenAI OAuth credentials file. + +If not specified, defaults to: +- `$CODEX_HOME/auth.json` if `CODEX_HOME` environment variable is set +- `~/.codex/auth.json` otherwise + +Refreshed tokens are automatically written back to the same location. + +#### usages_path + +Path to the file for storing aggregated API usage statistics. + +Usage tracking is disabled if not specified. + +When enabled, the service tracks and saves comprehensive statistics including: +- Request counts +- Token usage (input, output, cached) +- Calculated costs in USD based on OpenAI API pricing + +Statistics are organized by model and optionally by user when authentication is enabled. + +The statistics file is automatically saved every minute and upon service shutdown. + +#### users + +List of authorized users for token authentication. + +If empty, no authentication is required. + +Object format: + +```json +{ + "name": "", + "token": "" +} +``` + +Object fields: + +- `name`: Username identifier for tracking purposes. +- `token`: Bearer token for authentication. Clients authenticate by setting the `Authorization: Bearer ` header. + +#### headers + +Custom HTTP headers to send to the OpenAI API. + +These headers will override any existing headers with the same name. + +#### detour + +Outbound tag for connecting to the OpenAI API. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +### Example + +#### Server + +```json +{ + "services": [ + { + "type": "ocm", + "listen": "127.0.0.1", + "listen_port": 8080 + } + ] +} +``` + +#### Client + +Add to `~/.codex/config.toml`: + +```toml +# profile = "ocm" # set as default profile + +[model_providers.ocm] +name = "OCM Proxy" +base_url = "http://127.0.0.1:8080/v1" +supports_websockets = true + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # if the latest model is not yet publicly released +# model_reasoning_effort = "xhigh" +``` + +Then run: + +```bash +codex --profile ocm +``` + +### Example with Authentication + +#### Server + +```json +{ + "services": [ + { + "type": "ocm", + "listen": "0.0.0.0", + "listen_port": 8080, + "usages_path": "./codex-usages.json", + "users": [ + { + "name": "alice", + "token": "sk-ocm-hello-world" + }, + { + "name": "bob", + "token": "sk-ocm-hello-bob" + } + ] + } + ] +} +``` + +#### Client + +Add to `~/.codex/config.toml`: + +```toml +# profile = "ocm" # set as default profile + +[model_providers.ocm] +name = "OCM Proxy" +base_url = "http://127.0.0.1:8080/v1" +supports_websockets = true +experimental_bearer_token = "sk-ocm-hello-world" + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # if the latest model is not yet publicly released +# model_reasoning_effort = "xhigh" +``` + +Then run: + +```bash +codex --profile ocm +``` diff --git a/docs/configuration/service/ocm.zh.md b/docs/configuration/service/ocm.zh.md new file mode 100644 index 00000000..90394006 --- /dev/null +++ b/docs/configuration/service/ocm.zh.md @@ -0,0 +1,186 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.13.0 起" + +# OCM + +OCM(OpenAI Codex 多路复用器)服务是一个多路复用服务,允许您通过自定义令牌远程访问本地的 OpenAI Codex 订阅。 + +它在本地机器上处理与 OpenAI API 的 OAuth 身份验证,同时允许远程客户端使用自定义令牌进行身份验证。 + +### 结构 + +```json +{ + "type": "ocm", + + ... // 监听字段 + + "credential_path": "", + "usages_path": "", + "users": [], + "headers": {}, + "detour": "", + "tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 + +### 字段 + +#### credential_path + +OpenAI OAuth 凭据文件的路径。 + +如果未指定,默认值为: +- 如果设置了 `CODEX_HOME` 环境变量,则使用 `$CODEX_HOME/auth.json` +- 否则使用 `~/.codex/auth.json` + +刷新的令牌会自动写回相同位置。 + +#### usages_path + +用于存储聚合 API 使用统计信息的文件路径。 + +如果未指定,使用跟踪将被禁用。 + +启用后,服务会跟踪并保存全面的统计信息,包括: +- 请求计数 +- 令牌使用量(输入、输出、缓存) +- 基于 OpenAI API 定价计算的美元成本 + +统计信息按模型以及可选的用户(启用身份验证时)进行组织。 + +统计文件每分钟自动保存一次,并在服务关闭时保存。 + +#### users + +用于令牌身份验证的授权用户列表。 + +如果为空,则不需要身份验证。 + +对象格式: + +```json +{ + "name": "", + "token": "" +} +``` + +对象字段: + +- `name`:用于跟踪的用户名标识符。 +- `token`:用于身份验证的 Bearer 令牌。客户端通过设置 `Authorization: Bearer ` 头进行身份验证。 + +#### headers + +发送到 OpenAI API 的自定义 HTTP 头。 + +这些头会覆盖同名的现有头。 + +#### detour + +用于连接 OpenAI API 的出站标签。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +### 示例 + +#### 服务端 + +```json +{ + "services": [ + { + "type": "ocm", + "listen": "127.0.0.1", + "listen_port": 8080 + } + ] +} +``` + +#### 客户端 + +在 `~/.codex/config.toml` 中添加: + +```toml +# profile = "ocm" # 设为默认配置 + + +[model_providers.ocm] +name = "OCM Proxy" +base_url = "http://127.0.0.1:8080/v1" +supports_websockets = true + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # 如果最新模型尚未公开发布 +# model_reasoning_effort = "xhigh" +``` + +然后运行: + +```bash +codex --profile ocm +``` + +### 带身份验证的示例 + +#### 服务端 + +```json +{ + "services": [ + { + "type": "ocm", + "listen": "0.0.0.0", + "listen_port": 8080, + "usages_path": "./codex-usages.json", + "users": [ + { + "name": "alice", + "token": "sk-ocm-hello-world" + }, + { + "name": "bob", + "token": "sk-ocm-hello-bob" + } + ] + } + ] +} +``` + +#### 客户端 + +在 `~/.codex/config.toml` 中添加: + +```toml +# profile = "ocm" # 设为默认配置 + +[model_providers.ocm] +name = "OCM Proxy" +base_url = "http://127.0.0.1:8080/v1" +supports_websockets = true +experimental_bearer_token = "sk-ocm-hello-world" + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # 如果最新模型尚未公开发布 +# model_reasoning_effort = "xhigh" +``` + +然后运行: + +```bash +codex --profile ocm +``` diff --git a/docs/configuration/service/resolved.md b/docs/configuration/service/resolved.md new file mode 100644 index 00000000..47b7e255 --- /dev/null +++ b/docs/configuration/service/resolved.md @@ -0,0 +1,44 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# Resolved + +Resolved service is a fake systemd-resolved DBUS service to receive DNS settings from other programs +(e.g. NetworkManager) and provide DNS resolution. + +See also: [Resolved DNS Server](/configuration/dns/server/resolved/) + +### Structure + +```json +{ + "type": "resolved", + + ... // Listen Fields +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### listen + +==Required== + +Listen address. + +`127.0.0.53` will be used by default. + +#### listen_port + +==Required== + +Listen port. + +`53` will be used by default. diff --git a/docs/configuration/service/resolved.zh.md b/docs/configuration/service/resolved.zh.md new file mode 100644 index 00000000..b8af4e95 --- /dev/null +++ b/docs/configuration/service/resolved.zh.md @@ -0,0 +1,44 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# Resolved + +Resolved 服务是一个伪造的 systemd-resolved DBUS 服务,用于从其他程序 +(如 NetworkManager)接收 DNS 设置并提供 DNS 解析。 + +另请参阅:[Resolved DNS 服务器](/zh/configuration/dns/server/resolved/) + +### 结构 + +```json +{ + "type": "resolved", + + ... // 监听字段 +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 + +### 字段 + +#### listen + +==必填== + +监听地址。 + +默认使用 `127.0.0.53`。 + +#### listen_port + +==必填== + +监听端口。 + +默认使用 `53`。 \ No newline at end of file diff --git a/docs/configuration/service/ssm-api.md b/docs/configuration/service/ssm-api.md new file mode 100644 index 00000000..1ef9f373 --- /dev/null +++ b/docs/configuration/service/ssm-api.md @@ -0,0 +1,58 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.12.0" + +# SSM API + +SSM API service is a RESTful API server for managing Shadowsocks servers. + +See https://github.com/Shadowsocks-NET/shadowsocks-specs/blob/main/2023-1-shadowsocks-server-management-api-v1.md + +### Structure + +```json +{ + "type": "ssm-api", + + ... // Listen Fields + + "servers": {}, + "cache_path": "", + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### servers + +==Required== + +A mapping Object from HTTP endpoints to [Shadowsocks Inbound](/configuration/inbound/shadowsocks) tags. + +Selected Shadowsocks inbounds must be configured with [managed](/configuration/inbound/shadowsocks#managed) enabled. + +Example: + +```json +{ + "servers": { + "/": "ss-in" + } +} +``` + +#### cache_path + +If set, when the server is about to stop, traffic and user state will be saved to the specified JSON file +to be restored on the next startup. + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). diff --git a/docs/configuration/service/ssm-api.zh.md b/docs/configuration/service/ssm-api.zh.md new file mode 100644 index 00000000..fbe45ebb --- /dev/null +++ b/docs/configuration/service/ssm-api.zh.md @@ -0,0 +1,58 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.12.0 起" + +# SSM API + +SSM API 服务是一个用于管理 Shadowsocks 服务器的 RESTful API 服务器。 + +参阅 https://github.com/Shadowsocks-NET/shadowsocks-specs/blob/main/2023-1-shadowsocks-server-management-api-v1.md + +### 结构 + +```json +{ + "type": "ssm-api", + + ... // 监听字段 + + "servers": {}, + "cache_path": "", + "tls": {} +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 + +### 字段 + +#### servers + +==必填== + +从 HTTP 端点到 [Shadowsocks 入站](/zh/configuration/inbound/shadowsocks) 标签的映射对象。 + +选定的 Shadowsocks 入站必须配置启用 [managed](/zh/configuration/inbound/shadowsocks#managed)。 + +示例: + +```json +{ + "servers": { + "/": "ss-in" + } +} +``` + +#### cache_path + +如果设置,当服务器即将停止时,流量和用户状态将保存到指定的 JSON 文件中, +以便在下次启动时恢复。 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/shared/certificate-provider/acme.md b/docs/configuration/shared/certificate-provider/acme.md new file mode 100644 index 00000000..440ed156 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/acme.md @@ -0,0 +1,150 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [account_key](#account_key) + :material-plus: [key_type](#key_type) + :material-plus: [detour](#detour) + +# ACME + +!!! quote "" + + `with_acme` build tag required. + +### Structure + +```json +{ + "type": "acme", + "tag": "", + + "domain": [], + "data_directory": "", + "default_server_name": "", + "email": "", + "provider": "", + "account_key": "", + "disable_http_challenge": false, + "disable_tls_alpn_challenge": false, + "alternative_http_port": 0, + "alternative_tls_port": 0, + "external_account": { + "key_id": "", + "mac_key": "" + }, + "dns01_challenge": {}, + "key_type": "", + "detour": "" +} +``` + +### Fields + +#### domain + +==Required== + +List of domains. + +#### data_directory + +The directory to store ACME data. + +`$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic` will be used if empty. + +#### default_server_name + +Server name to use when choosing a certificate if the ClientHello's ServerName field is empty. + +#### email + +The email address to use when creating or selecting an existing ACME server account. + +#### provider + +The ACME CA provider to use. + +| Value | Provider | +|-------------------------|---------------| +| `letsencrypt (default)` | Let's Encrypt | +| `zerossl` | ZeroSSL | +| `https://...` | Custom | + +When `provider` is `zerossl`, sing-box will automatically request ZeroSSL EAB credentials if `email` is set and +`external_account` is empty. + +When `provider` is `zerossl`, at least one of `external_account`, `email`, or `account_key` is required. + +#### account_key + +!!! question "Since sing-box 1.14.0" + +The PEM-encoded private key of an existing ACME account. + +#### disable_http_challenge + +Disable all HTTP challenges. + +#### disable_tls_alpn_challenge + +Disable all TLS-ALPN challenges + +#### alternative_http_port + +The alternate port to use for the ACME HTTP challenge; if non-empty, this port will be used instead of 80 to spin up a +listener for the HTTP challenge. + +#### alternative_tls_port + +The alternate port to use for the ACME TLS-ALPN challenge; the system must forward 443 to this port for challenge to +succeed. + +#### external_account + +EAB (External Account Binding) contains information necessary to bind or map an ACME account to some other account known +by the CA. + +External account bindings are used to associate an ACME account with an existing account in a non-ACME system, such as +a CA customer database. + +To enable ACME account binding, the CA operating the ACME server needs to provide the ACME client with a MAC key and a +key identifier, using some mechanism outside of ACME. §7.3.4 + +#### external_account.key_id + +The key identifier. + +#### external_account.mac_key + +The MAC key. + +#### dns01_challenge + +ACME DNS01 challenge field. If configured, other challenge methods will be disabled. + +See [DNS01 Challenge Fields](/configuration/shared/dns01_challenge/) for details. + +#### key_type + +!!! question "Since sing-box 1.14.0" + +The private key type to generate for new certificates. + +| Value | Type | +|------------|---------| +| `ed25519` | Ed25519 | +| `p256` | P-256 | +| `p384` | P-384 | +| `rsa2048` | RSA | +| `rsa4096` | RSA | + +#### detour + +!!! question "Since sing-box 1.14.0" + +The tag of the upstream outbound. + +All provider HTTP requests will use this outbound. diff --git a/docs/configuration/shared/certificate-provider/acme.zh.md b/docs/configuration/shared/certificate-provider/acme.zh.md new file mode 100644 index 00000000..d95930a5 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/acme.zh.md @@ -0,0 +1,145 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [account_key](#account_key) + :material-plus: [key_type](#key_type) + :material-plus: [detour](#detour) + +# ACME + +!!! quote "" + + 需要 `with_acme` 构建标签。 + +### 结构 + +```json +{ + "type": "acme", + "tag": "", + + "domain": [], + "data_directory": "", + "default_server_name": "", + "email": "", + "provider": "", + "account_key": "", + "disable_http_challenge": false, + "disable_tls_alpn_challenge": false, + "alternative_http_port": 0, + "alternative_tls_port": 0, + "external_account": { + "key_id": "", + "mac_key": "" + }, + "dns01_challenge": {}, + "key_type": "", + "detour": "" +} +``` + +### 字段 + +#### domain + +==必填== + +域名列表。 + +#### data_directory + +ACME 数据存储目录。 + +如果为空则使用 `$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic`。 + +#### default_server_name + +如果 ClientHello 的 ServerName 字段为空,则选择证书时要使用的服务器名称。 + +#### email + +创建或选择现有 ACME 服务器帐户时使用的电子邮件地址。 + +#### provider + +要使用的 ACME CA 提供商。 + +| 值 | 提供商 | +|--------------------|---------------| +| `letsencrypt (默认)` | Let's Encrypt | +| `zerossl` | ZeroSSL | +| `https://...` | 自定义 | + +当 `provider` 为 `zerossl` 时,如果设置了 `email` 且未设置 `external_account`, +sing-box 会自动向 ZeroSSL 请求 EAB 凭据。 + +当 `provider` 为 `zerossl` 时,必须至少设置 `external_account`、`email` 或 `account_key` 之一。 + +#### account_key + +!!! question "自 sing-box 1.14.0 起" + +现有 ACME 帐户的 PEM 编码私钥。 + +#### disable_http_challenge + +禁用所有 HTTP 质询。 + +#### disable_tls_alpn_challenge + +禁用所有 TLS-ALPN 质询。 + +#### alternative_http_port + +用于 ACME HTTP 质询的备用端口;如果非空,将使用此端口而不是 80 来启动 HTTP 质询的侦听器。 + +#### alternative_tls_port + +用于 ACME TLS-ALPN 质询的备用端口; 系统必须将 443 转发到此端口以使质询成功。 + +#### external_account + +EAB(外部帐户绑定)包含将 ACME 帐户绑定或映射到 CA 已知的其他帐户所需的信息。 + +外部帐户绑定用于将 ACME 帐户与非 ACME 系统中的现有帐户相关联,例如 CA 客户数据库。 + +为了启用 ACME 帐户绑定,运行 ACME 服务器的 CA 需要使用 ACME 之外的某种机制向 ACME 客户端提供 MAC 密钥和密钥标识符。§7.3.4 + +#### external_account.key_id + +密钥标识符。 + +#### external_account.mac_key + +MAC 密钥。 + +#### dns01_challenge + +ACME DNS01 质询字段。如果配置,将禁用其他质询方法。 + +参阅 [DNS01 质询字段](/zh/configuration/shared/dns01_challenge/)。 + +#### key_type + +!!! question "自 sing-box 1.14.0 起" + +为新证书生成的私钥类型。 + +| 值 | 类型 | +|-----------|----------| +| `ed25519` | Ed25519 | +| `p256` | P-256 | +| `p384` | P-384 | +| `rsa2048` | RSA | +| `rsa4096` | RSA | + +#### detour + +!!! question "自 sing-box 1.14.0 起" + +上游出站的标签。 + +所有提供者 HTTP 请求将使用此出站。 diff --git a/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md new file mode 100644 index 00000000..cfd2da4f --- /dev/null +++ b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Cloudflare Origin CA + +### Structure + +```json +{ + "type": "cloudflare-origin-ca", + "tag": "", + + "domain": [], + "data_directory": "", + "api_token": "", + "origin_ca_key": "", + "request_type": "", + "requested_validity": 0, + "detour": "" +} +``` + +### Fields + +#### domain + +==Required== + +List of domain names or wildcard domain names to include in the certificate. + +#### data_directory + +Root directory used to store the issued certificate, private key, and metadata. + +If empty, sing-box uses the same default data directory as the ACME certificate provider: +`$XDG_DATA_HOME/certmagic` or `$HOME/.local/share/certmagic`. + +#### api_token + +Cloudflare API token used to create the certificate. + +Get or create one in [Cloudflare Dashboard > My Profile > API Tokens](https://dash.cloudflare.com/profile/api-tokens). + +Requires the `Zone / SSL and Certificates / Edit` permission. + +Conflict with `origin_ca_key`. + +#### origin_ca_key + +Cloudflare Origin CA Key. + +Get it in [Cloudflare Dashboard > My Profile > API Tokens > API Keys > Origin CA Key](https://dash.cloudflare.com/profile/api-tokens). + +Conflict with `api_token`. + +#### request_type + +The signature type to request from Cloudflare. + +| Value | Type | +|----------------------|-------------| +| `origin-rsa` | RSA | +| `origin-ecc` | ECDSA P-256 | + +`origin-rsa` is used if empty. + +#### requested_validity + +The requested certificate validity in days. + +Available values: `7`, `30`, `90`, `365`, `730`, `1095`, `5475`. + +`5475` days (15 years) is used if empty. + +#### detour + +The tag of the upstream outbound. + +All provider HTTP requests will use this outbound. diff --git a/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md new file mode 100644 index 00000000..85036268 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# Cloudflare Origin CA + +### 结构 + +```json +{ + "type": "cloudflare-origin-ca", + "tag": "", + + "domain": [], + "data_directory": "", + "api_token": "", + "origin_ca_key": "", + "request_type": "", + "requested_validity": 0, + "detour": "" +} +``` + +### 字段 + +#### domain + +==必填== + +要写入证书的域名或通配符域名列表。 + +#### data_directory + +保存签发证书、私钥和元数据的根目录。 + +如果为空,sing-box 会使用与 ACME 证书提供者相同的默认数据目录: +`$XDG_DATA_HOME/certmagic` 或 `$HOME/.local/share/certmagic`。 + +#### api_token + +用于创建证书的 Cloudflare API Token。 + +可在 [Cloudflare Dashboard > My Profile > API Tokens](https://dash.cloudflare.com/profile/api-tokens) 获取或创建。 + +需要 `Zone / SSL and Certificates / Edit` 权限。 + +与 `origin_ca_key` 冲突。 + +#### origin_ca_key + +Cloudflare Origin CA Key。 + +可在 [Cloudflare Dashboard > My Profile > API Tokens > API Keys > Origin CA Key](https://dash.cloudflare.com/profile/api-tokens) 获取。 + +与 `api_token` 冲突。 + +#### request_type + +向 Cloudflare 请求的签名类型。 + +| 值 | 类型 | +|----------------------|-------------| +| `origin-rsa` | RSA | +| `origin-ecc` | ECDSA P-256 | + +如果为空,使用 `origin-rsa`。 + +#### requested_validity + +请求的证书有效期,单位为天。 + +可用值:`7`、`30`、`90`、`365`、`730`、`1095`、`5475`。 + +如果为空,使用 `5475` 天(15 年)。 + +#### detour + +上游出站的标签。 + +所有提供者 HTTP 请求将使用此出站。 diff --git a/docs/configuration/shared/certificate-provider/index.md b/docs/configuration/shared/certificate-provider/index.md new file mode 100644 index 00000000..c493550a --- /dev/null +++ b/docs/configuration/shared/certificate-provider/index.md @@ -0,0 +1,32 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Certificate Provider + +### Structure + +```json +{ + "certificate_providers": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### Fields + +| Type | Format | +|--------|------------------| +| `acme` | [ACME](/configuration/shared/certificate-provider/acme) | +| `tailscale` | [Tailscale](/configuration/shared/certificate-provider/tailscale) | +| `cloudflare-origin-ca` | [Cloudflare Origin CA](/configuration/shared/certificate-provider/cloudflare-origin-ca) | + +#### tag + +The tag of the certificate provider. diff --git a/docs/configuration/shared/certificate-provider/index.zh.md b/docs/configuration/shared/certificate-provider/index.zh.md new file mode 100644 index 00000000..2df4b363 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/index.zh.md @@ -0,0 +1,32 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# 证书提供者 + +### 结构 + +```json +{ + "certificate_providers": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### 字段 + +| 类型 | 格式 | +|--------|------------------| +| `acme` | [ACME](/zh/configuration/shared/certificate-provider/acme) | +| `tailscale` | [Tailscale](/zh/configuration/shared/certificate-provider/tailscale) | +| `cloudflare-origin-ca` | [Cloudflare Origin CA](/zh/configuration/shared/certificate-provider/cloudflare-origin-ca) | + +#### tag + +证书提供者的标签。 diff --git a/docs/configuration/shared/certificate-provider/tailscale.md b/docs/configuration/shared/certificate-provider/tailscale.md new file mode 100644 index 00000000..045f2c5e --- /dev/null +++ b/docs/configuration/shared/certificate-provider/tailscale.md @@ -0,0 +1,27 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Tailscale + +### Structure + +```json +{ + "type": "tailscale", + "tag": "ts-cert", + "endpoint": "ts-ep" +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [Tailscale endpoint](/configuration/endpoint/tailscale/) to reuse. + +[MagicDNS and HTTPS](https://tailscale.com/kb/1153/enabling-https) must be enabled in the Tailscale admin console. diff --git a/docs/configuration/shared/certificate-provider/tailscale.zh.md b/docs/configuration/shared/certificate-provider/tailscale.zh.md new file mode 100644 index 00000000..1987da50 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/tailscale.zh.md @@ -0,0 +1,27 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# Tailscale + +### 结构 + +```json +{ + "type": "tailscale", + "tag": "ts-cert", + "endpoint": "ts-ep" +} +``` + +### 字段 + +#### endpoint + +==必填== + +要复用的 [Tailscale 端点](/zh/configuration/endpoint/tailscale/) 的标签。 + +必须在 Tailscale 管理控制台中启用 [MagicDNS 和 HTTPS](https://tailscale.com/kb/1153/enabling-https)。 diff --git a/docs/configuration/shared/dial.md b/docs/configuration/shared/dial.md new file mode 100644 index 00000000..306952fc --- /dev/null +++ b/docs/configuration/shared/dial.md @@ -0,0 +1,270 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) + :material-plus: [tcp_keep_alive](#tcp_keep_alive) + :material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval) + :material-plus: [bind_address_no_port](#bind_address_no_port) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [domain_resolver](#domain_resolver) + :material-delete-clock: [domain_strategy](#domain_strategy) + :material-plus: [netns](#netns) + +!!! quote "Changes in sing-box 1.11.0" + + :material-plus: [network_strategy](#network_strategy) + :material-alert: [fallback_delay](#fallback_delay) + :material-alert: [network_type](#network_type) + :material-alert: [fallback_network_type](#fallback_network_type) + +### Structure + +```json +{ + "detour": "", + "bind_interface": "", + "inet4_bind_address": "", + "inet6_bind_address": "", + "bind_address_no_port": false, + "routing_mark": 0, + "reuse_addr": false, + "netns": "", + "connect_timeout": "", + "tcp_fast_open": false, + "tcp_multi_path": false, + "disable_tcp_keep_alive": false, + "tcp_keep_alive": "", + "tcp_keep_alive_interval": "", + "udp_fragment": false, + + "domain_resolver": "", // or {} + "network_strategy": "", + "network_type": [], + "fallback_network_type": [], + "fallback_delay": "", + + // Deprecated + + "domain_strategy": "" +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### detour + +The tag of the upstream outbound. + +If enabled, all other fields will be ignored. + +#### bind_interface + +The network interface to bind to. + +#### inet4_bind_address + +The IPv4 address to bind to. + +#### inet6_bind_address + +The IPv6 address to bind to. + +#### bind_address_no_port + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux. + +Do not reserve a port when binding to a source address. + +This allows reusing the same source port for multiple connections if the full 4-tuple (source IP, source port, destination IP, destination port) remains unique. + +#### routing_mark + +!!! quote "" + + Only supported on Linux. + +Set netfilter routing mark. + +Integers (e.g. `1234`) and string hexadecimals (e.g. `"0x1234"`) are supported. + +#### reuse_addr + +Reuse listener address. + +#### netns + +!!! question "Since sing-box 1.12.0" + +!!! quote "" + + Only supported on Linux. + +Set network namespace, name or path. + +#### connect_timeout + +Connect timeout, in golang's Duration format. + +A duration string is a possibly signed sequence of +decimal numbers, each with optional fraction and a unit suffix, +such as "300ms", "-1.5h" or "2h45m". +Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + +#### tcp_fast_open + +Enable TCP Fast Open. + +#### tcp_multi_path + +!!! warning "" + + Go 1.21 required. + +Enable TCP Multi Path. + +#### disable_tcp_keep_alive + +!!! question "Since sing-box 1.13.0" + +Disable TCP keep alive. + +#### tcp_keep_alive + +!!! question "Since sing-box 1.13.0" + + Default value changed from `10m` to `5m`. + +TCP keep alive initial period. + +`5m` will be used by default. + +#### tcp_keep_alive_interval + +!!! question "Since sing-box 1.13.0" + +TCP keep alive interval. + +`75s` will be used by default. + +#### udp_fragment + +Enable UDP fragmentation. + +#### domain_resolver + +!!! warning "" + + `outbound` DNS rule items are deprecated and will be removed in sing-box 1.14.0, so this item will be required for outbound/endpoints using domain name in server address since sing-box 1.14.0. + +!!! info "" + + `domain_resolver` or `route.default_domain_resolver` is optional when only one DNS server is configured. + +Set domain resolver to use for resolving domain names. + +This option uses the same format as the [route DNS rule action](/configuration/dns/rule_action/#route) without the `action` field. + +Setting this option directly to a string is equivalent to setting `server` of this options. + +| Outbound/Endpoints | Effected domains | +|--------------------|--------------------------| +| `direct` | Domain in request | +| others | Domain in server address | + +#### network_strategy + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms with `auto_detect_interface` enabled. + +Strategy for selecting network interfaces. + +Available values: + +- `default` (default): Connect to default network or networks specified in `network_type` sequentially. +- `hybrid`: Connect to all networks or networks specified in `network_type` concurrently. +- `fallback`: Connect to default network or preferred networks specified in `network_type` concurrently, and try fallback networks when unavailable or timeout. + +For fallback, when preferred interfaces fails or times out, +it will enter a 15s fast fallback state (Connect to all preferred and fallback networks concurrently), +and exit immediately if preferred networks recover. + +Conflicts with `bind_interface`, `inet4_bind_address` and `inet6_bind_address`. + +#### network_type + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms with `auto_detect_interface` enabled. + +Network types to use when using `default` or `hybrid` network strategy or +preferred network types to use when using `fallback` network strategy. + +Available values: `wifi`, `cellular`, `ethernet`, `other`. + +Device's default network is used by default. + +#### fallback_network_type + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms with `auto_detect_interface` enabled. + +Fallback network types when preferred networks are unavailable or timeout when using `fallback` network strategy. + +All other networks expect preferred are used by default. + +#### fallback_delay + +!!! question "Since sing-box 1.11.0" + +!!! quote "" + + Only supported in graphical clients on Android and Apple platforms with `auto_detect_interface` enabled. + +The length of time to wait before spawning a RFC 6555 Fast Fallback connection. + +For `domain_strategy`, is the amount of time to wait for connection to succeed before assuming +that IPv4/IPv6 is misconfigured and falling back to other type of addresses. + +For `network_strategy`, is the amount of time to wait for connection to succeed before falling +back to other interfaces. + +Only take effect when `domain_strategy` or `network_strategy` is set. + +`300ms` is used by default. + +#### domain_strategy + +!!! failure "Deprecated in sing-box 1.12.0" + + `domain_strategy` is deprecated and will be removed in sing-box 1.14.0, check [Migration](/migration/#migrate-outbound-domain-strategy-option-to-domain-resolver). + +Available values: `prefer_ipv4`, `prefer_ipv6`, `ipv4_only`, `ipv6_only`. + +If set, the requested domain name will be resolved to IP before connect. + +| Outbound | Effected domains | Fallback Value | +|----------|--------------------------|-------------------------------------------| +| `direct` | Domain in request | Take `inbound.domain_strategy` if not set | +| others | Domain in server address | / | + diff --git a/docs/configuration/shared/dial.zh.md b/docs/configuration/shared/dial.zh.md new file mode 100644 index 00000000..daf7f8e0 --- /dev/null +++ b/docs/configuration/shared/dial.zh.md @@ -0,0 +1,258 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) + :material-plus: [tcp_keep_alive](#tcp_keep_alive) + :material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval) + :material-plus: [bind_address_no_port](#bind_address_no_port) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [domain_resolver](#domain_resolver) + :material-delete-clock: [domain_strategy](#domain_strategy) + :material-plus: [netns](#netns) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-plus: [network_strategy](#network_strategy) + :material-alert: [fallback_delay](#fallback_delay) + :material-alert: [network_type](#network_type) + :material-alert: [fallback_network_type](#fallback_network_type) + +### 结构 + +```json +{ + "detour": "", + "bind_interface": "", + "inet4_bind_address": "", + "inet6_bind_address": "", + "bind_address_no_port": false, + "routing_mark": 0, + "reuse_addr": false, + "netns": "", + "connect_timeout": "", + "tcp_fast_open": false, + "tcp_multi_path": false, + "disable_tcp_keep_alive": false, + "tcp_keep_alive": "", + "tcp_keep_alive_interval": "", + "udp_fragment": false, + + "domain_resolver": "", // 或 {} + "network_strategy": "", + "network_type": [], + "fallback_network_type": [], + "fallback_delay": "", + + // 废弃的 + + "domain_strategy": "" +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 字段 + +#### detour + +上游出站的标签。 + +启用时,其他拨号字段将被忽略。 + +#### bind_interface + +要绑定到的网络接口。 + +#### inet4_bind_address + +要绑定的 IPv4 地址。 + +#### inet6_bind_address + +要绑定的 IPv6 地址。 + +#### bind_address_no_port + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux。 + +绑定到源地址时不保留端口。 + +这允许在完整的四元组(源 IP、源端口、目标 IP、目标端口)保持唯一的情况下,为多个连接复用同一源端口。 + +#### routing_mark + +!!! quote "" + + 仅支持 Linux。 + +设置 netfilter 路由标记。 + +支持数字 (如 `1234`) 和十六进制字符串 (如 `"0x1234"`)。 + +#### reuse_addr + +重用监听地址。 + +#### netns + +!!! question "自 sing-box 1.12.0 起" + +!!! quote "" + + 仅支持 Linux。 + +设置网络命名空间,名称或路径。 + +#### connect_timeout + +连接超时,采用 golang 的 Duration 格式。 + +持续时间字符串是一个可能有符号的序列十进制数,每个都有可选的分数和单位后缀, 例如 "300ms"、"-1.5h" 或 "2h45m"。 +有效时间单位为 "ns"、"us"(或 "µs")、"ms"、"s"、"m"、"h"。 + +#### tcp_fast_open + +启用 TCP Fast Open。 + +#### tcp_multi_path + +!!! warning "" + + 需要 Go 1.21。 + +启用 TCP Multi Path。 + +#### disable_tcp_keep_alive + +!!! question "自 sing-box 1.13.0 起" + +禁用 TCP keep alive。 + +#### tcp_keep_alive + +!!! question "自 sing-box 1.13.0 起" + + 默认值从 `10m` 更改为 `5m`。 + +TCP keep alive 初始周期。 + +默认使用 `5m`。 + +#### tcp_keep_alive_interval + +!!! question "自 sing-box 1.13.0 起" + +TCP keep alive 间隔。 + +默认使用 `75s`。 + +#### udp_fragment + +启用 UDP 分段。 + +#### domain_resolver + +!!! warning "" + + `outbound` DNS 规则项已弃用,且将在 sing-box 1.14.0 中被移除。因此,从 sing-box 1.14.0 版本开始,所有在服务器地址中使用域名的出站/端点均需配置此项。 + +!!! info "" + + 当只有一个 DNS 服务器已配置时,`domain_resolver` 或 `route.default_domain_resolver` 是可选的。 + +用于设置解析域名的域名解析器。 + +此选项的格式与 [路由 DNS 规则动作](/zh/configuration/dns/rule_action/#route) 相同,但不包含 `action` 字段。 + +若直接将此选项设置为字符串,则等同于设置该选项的 `server` 字段。 + +| 出站/端点 | 受影响的域名 | +|----------------|---------------------------| +| `direct` | 请求中的域名 | +| 其他类型 | 服务器地址中的域名 | + +#### network_strategy + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 iOS 平台图形客户端中支持,并且需要 `route.auto_detect_interface`。 + +用于选择网络接口的策略。 + +可用值: + +- `default`(默认值):按顺序连接默认网络或 `network_type` 中指定的网络。 +- `hybrid`:同时连接所有网络或 `network_type` 中指定的网络。 +- `fallback`:同时连接默认网络或 `network_type` 中指定的首选网络,当不可用或超时时尝试回退网络。 + +对于回退模式,当首选接口失败或超时时, +将进入15秒的快速回退状态(同时连接所有首选和回退网络), +如果首选网络恢复,则立即退出。 + +与 `bind_interface`, `bind_inet4_address` 和 `bind_inet6_address` 冲突。 + +#### network_type + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 iOS 平台图形客户端中支持,并且需要 `route.auto_detect_interface`。 + +当使用 `default` 或 `hybrid` 网络策略时要使用的网络类型,或当使用 `fallback` 网络策略时要使用的首选网络类型。 + +可用值:`wifi`, `cellular`, `ethernet`, `other`。 + +默认使用设备默认网络。 + +#### fallback_network_type + +!!! question "自 sing-box 1.11.0 起" + +!!! quote "" + + 仅在 Android 与 iOS 平台图形客户端中支持,并且需要 `route.auto_detect_interface`。 + +当使用 `fallback` 网络策略时,在首选网络不可用或超时的情况下要使用的回退网络类型。 + +默认使用除首选网络外的所有其他网络。 + +#### fallback_delay + +在生成 RFC 6555 快速回退连接之前等待的时间长度。 + +对于 `domain_strategy`,是在假设之前等待 IPv6 成功的时间量如果设置了 "prefer_ipv4",则 IPv6 配置错误并回退到 IPv4。 + +对于 `network_strategy`,对于 `network_strategy`,是在回退到其他接口之前等待连接成功的时间。 + +仅当 `domain_strategy` 或 `network_strategy` 已设置时生效。 + +默认使用 `300ms`。 + +#### domain_strategy + +!!! failure "已在 sing-box 1.12.0 废弃" + + `domain_strategy` 已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移出站域名策略选项到域名解析器)。 + +可选值:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 + +如果设置,域名将在请求发出之前解析为 IP。 + +| 出站 | 受影响的域名 | 默认回退值 | +|----------|-----------|---------------------------| +| `direct` | 请求中的域名 | `inbound.domain_strategy` | +| others | 服务器地址中的域名 | / | \ No newline at end of file diff --git a/docs/configuration/shared/dns01_challenge.md b/docs/configuration/shared/dns01_challenge.md new file mode 100644 index 00000000..0157cb45 --- /dev/null +++ b/docs/configuration/shared/dns01_challenge.md @@ -0,0 +1,126 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [ttl](#ttl) + :material-plus: [propagation_delay](#propagation_delay) + :material-plus: [propagation_timeout](#propagation_timeout) + :material-plus: [resolvers](#resolvers) + :material-plus: [override_domain](#override_domain) + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [alidns.security_token](#security_token) + :material-plus: [cloudflare.zone_token](#zone_token) + :material-plus: [acmedns](#acmedns) + +### Structure + +```json +{ + "ttl": "", + "propagation_delay": "", + "propagation_timeout": "", + "resolvers": [], + "override_domain": "", + "provider": "", + + ... // Provider Fields +} +``` + +### Fields + +#### ttl + +!!! question "Since sing-box 1.14.0" + +The TTL of the temporary TXT record used for the DNS challenge. + +#### propagation_delay + +!!! question "Since sing-box 1.14.0" + +How long to wait after creating the challenge record before starting propagation checks. + +#### propagation_timeout + +!!! question "Since sing-box 1.14.0" + +The maximum time to wait for the challenge record to propagate. + +Set to `-1` to disable propagation checks. + +#### resolvers + +!!! question "Since sing-box 1.14.0" + +Preferred DNS resolvers to use for DNS propagation checks. + +#### override_domain + +!!! question "Since sing-box 1.14.0" + +Override the domain name used for the DNS challenge record. + +Useful when `_acme-challenge` is delegated to a different zone. + +#### provider + +The DNS provider. See below for provider-specific fields. + +### Provider Fields + +#### Alibaba Cloud DNS + +```json +{ + "provider": "alidns", + "access_key_id": "", + "access_key_secret": "", + "region_id": "", + "security_token": "" +} +``` + +##### security_token + +!!! question "Since sing-box 1.13.0" + +The Security Token for STS temporary credentials. + +#### Cloudflare + +```json +{ + "provider": "cloudflare", + "api_token": "", + "zone_token": "" +} +``` + +##### zone_token + +!!! question "Since sing-box 1.13.0" + +Optional API token with `Zone:Read` permission. + +When provided, allows `api_token` to be scoped to a single zone. + +#### ACME-DNS + +!!! question "Since sing-box 1.13.0" + +```json +{ + "provider": "acmedns", + "username": "", + "password": "", + "subdomain": "", + "server_url": "" +} +``` + +See [ACME-DNS](https://github.com/joohoi/acme-dns) for details. diff --git a/docs/configuration/shared/dns01_challenge.zh.md b/docs/configuration/shared/dns01_challenge.zh.md new file mode 100644 index 00000000..8c582bb5 --- /dev/null +++ b/docs/configuration/shared/dns01_challenge.zh.md @@ -0,0 +1,126 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [ttl](#ttl) + :material-plus: [propagation_delay](#propagation_delay) + :material-plus: [propagation_timeout](#propagation_timeout) + :material-plus: [resolvers](#resolvers) + :material-plus: [override_domain](#override_domain) + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [alidns.security_token](#security_token) + :material-plus: [cloudflare.zone_token](#zone_token) + :material-plus: [acmedns](#acmedns) + +### 结构 + +```json +{ + "ttl": "", + "propagation_delay": "", + "propagation_timeout": "", + "resolvers": [], + "override_domain": "", + "provider": "", + + ... // 提供商字段 +} +``` + +### 字段 + +#### ttl + +!!! question "自 sing-box 1.14.0 起" + +DNS 质询临时 TXT 记录的 TTL。 + +#### propagation_delay + +!!! question "自 sing-box 1.14.0 起" + +创建质询记录后,在开始传播检查前要等待的时间。 + +#### propagation_timeout + +!!! question "自 sing-box 1.14.0 起" + +等待质询记录传播完成的最长时间。 + +设为 `-1` 可禁用传播检查。 + +#### resolvers + +!!! question "自 sing-box 1.14.0 起" + +进行 DNS 传播检查时优先使用的 DNS 解析器。 + +#### override_domain + +!!! question "自 sing-box 1.14.0 起" + +覆盖 DNS 质询记录使用的域名。 + +适用于将 `_acme-challenge` 委托到其他 zone 的场景。 + +#### provider + +DNS 提供商。提供商专有字段见下文。 + +### 提供商字段 + +#### Alibaba Cloud DNS + +```json +{ + "provider": "alidns", + "access_key_id": "", + "access_key_secret": "", + "region_id": "", + "security_token": "" +} +``` + +##### security_token + +!!! question "自 sing-box 1.13.0 起" + +用于 STS 临时凭证的安全令牌。 + +#### Cloudflare + +```json +{ + "provider": "cloudflare", + "api_token": "", + "zone_token": "" +} +``` + +##### zone_token + +!!! question "自 sing-box 1.13.0 起" + +具有 `Zone:Read` 权限的可选 API 令牌。 + +提供后可将 `api_token` 限定到单个区域。 + +#### ACME-DNS + +!!! question "自 sing-box 1.13.0 起" + +```json +{ + "provider": "acmedns", + "username": "", + "password": "", + "subdomain": "", + "server_url": "" +} +``` + +参阅 [ACME-DNS](https://github.com/joohoi/acme-dns)。 diff --git a/docs/configuration/shared/listen.md b/docs/configuration/shared/listen.md new file mode 100644 index 00000000..55325564 --- /dev/null +++ b/docs/configuration/shared/listen.md @@ -0,0 +1,202 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) + :material-alert: [tcp_keep_alive](#tcp_keep_alive) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [netns](#netns) + :material-plus: [bind_interface](#bind_interface) + :material-plus: [routing_mark](#routing_mark) + :material-plus: [reuse_addr](#reuse_addr) + +!!! quote "Changes in sing-box 1.11.0" + + :material-delete-clock: [sniff](#sniff) + :material-delete-clock: [sniff_override_destination](#sniff_override_destination) + :material-delete-clock: [sniff_timeout](#sniff_timeout) + :material-delete-clock: [domain_strategy](#domain_strategy) + :material-delete-clock: [udp_disable_domain_unmapping](#udp_disable_domain_unmapping) + +### Structure + +```json +{ + "listen": "", + "listen_port": 0, + "bind_interface": "", + "routing_mark": 0, + "reuse_addr": false, + "netns": "", + "tcp_fast_open": false, + "tcp_multi_path": false, + "disable_tcp_keep_alive": false, + "tcp_keep_alive": "", + "tcp_keep_alive_interval": "", + "udp_fragment": false, + "udp_timeout": "", + "detour": "", + + // Deprecated + + "sniff": false, + "sniff_override_destination": false, + "sniff_timeout": "", + "domain_strategy": "", + "udp_disable_domain_unmapping": false +} +``` + +### Fields + +#### listen + +==Required== + +Listen address. + +#### listen_port + +Listen port. + +#### bind_interface + +!!! question "Since sing-box 1.12.0" + +The network interface to bind to. + +#### routing_mark + +!!! question "Since sing-box 1.12.0" + +!!! quote "" + + Only supported on Linux. + +Set netfilter routing mark. + +Integers (e.g. `1234`) and string hexadecimals (e.g. `"0x1234"`) are supported. + +#### reuse_addr + +!!! question "Since sing-box 1.12.0" + +Reuse listener address. + +#### netns + +!!! question "Since sing-box 1.12.0" + +!!! quote "" + + Only supported on Linux. + +Set network namespace, name or path. + +#### tcp_fast_open + +Enable TCP Fast Open. + +#### tcp_multi_path + +!!! warning "" + + Go 1.21 required. + +Enable TCP Multi Path. + +#### disable_tcp_keep_alive + +!!! question "Since sing-box 1.13.0" + +Disable TCP keep alive. + +#### tcp_keep_alive + +!!! question "Since sing-box 1.13.0" + + Default value changed from `10m` to `5m`. + +TCP keep alive initial period. + +`5m` will be used by default. + +#### tcp_keep_alive_interval + +TCP keep alive interval. + +`75s` will be used by default. + +#### udp_fragment + +Enable UDP fragmentation. + +#### udp_timeout + +UDP NAT expiration time. + +`5m` will be used by default. + +#### detour + +If set, connections will be forwarded to the specified inbound. + +Requires target inbound support, see [Injectable](/configuration/inbound/#fields). + +#### sniff + +!!! failure "Deprecated in sing-box 1.11.0" + + Inbound fields are deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + +Enable sniffing. + +See [Protocol Sniff](/configuration/route/sniff/) for details. + +#### sniff_override_destination + +!!! failure "Deprecated in sing-box 1.11.0" + + Inbound fields are deprecated and will be removed in sing-box 1.13.0. + +Override the connection destination address with the sniffed domain. + +If the domain name is invalid (like tor), this will not work. + +#### sniff_timeout + +!!! failure "Deprecated in sing-box 1.11.0" + + Inbound fields are deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + +Timeout for sniffing. + +`300ms` is used by default. + +#### domain_strategy + +!!! failure "Deprecated in sing-box 1.11.0" + + Inbound fields are deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + +One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. + +If set, the requested domain name will be resolved to IP before routing. + +If `sniff_override_destination` is in effect, its value will be taken as a fallback. + +#### udp_disable_domain_unmapping + +!!! failure "Deprecated in sing-box 1.11.0" + + Inbound fields are deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + +If enabled, for UDP proxy requests addressed to a domain, +the original packet address will be sent in the response instead of the mapped domain. + +This option is used for compatibility with clients that +do not support receiving UDP packets with domain addresses, such as Surge. diff --git a/docs/configuration/shared/listen.zh.md b/docs/configuration/shared/listen.zh.md new file mode 100644 index 00000000..0afcbc46 --- /dev/null +++ b/docs/configuration/shared/listen.zh.md @@ -0,0 +1,200 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) + :material-alert: [tcp_keep_alive](#tcp_keep_alive) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [netns](#netns) + :material-plus: [bind_interface](#bind_interface) + :material-plus: [routing_mark](#routing_mark) + :material-plus: [reuse_addr](#reuse_addr) + +!!! quote "sing-box 1.11.0 中的更改" + + :material-delete-clock: [sniff](#sniff) + :material-delete-clock: [sniff_override_destination](#sniff_override_destination) + :material-delete-clock: [sniff_timeout](#sniff_timeout) + :material-delete-clock: [domain_strategy](#domain_strategy) + :material-delete-clock: [udp_disable_domain_unmapping](#udp_disable_domain_unmapping) + +### 结构 + +```json +{ + "listen": "", + "listen_port": 0, + "bind_interface": "", + "routing_mark": 0, + "reuse_addr": false, + "netns": "", + "tcp_fast_open": false, + "tcp_multi_path": false, + "disable_tcp_keep_alive": false, + "tcp_keep_alive": "", + "tcp_keep_alive_interval": "", + "udp_fragment": false, + "udp_timeout": "", + "detour": "", + + // 废弃的 + + "sniff": false, + "sniff_override_destination": false, + "sniff_timeout": "", + "domain_strategy": "", + "udp_disable_domain_unmapping": false +} +``` + +### 字段 + +#### listen + +==必填== + +监听地址。 + +#### listen_port + +监听端口。 + +#### bind_interface + +!!! question "自 sing-box 1.12.0 起" + +要绑定到的网络接口。 + +#### routing_mark + +!!! question "自 sing-box 1.12.0 起" + +!!! quote "" + + 仅支持 Linux。 + +设置 netfilter 路由标记。 + +支持数字 (如 `1234`) 和十六进制字符串 (如 `"0x1234"`)。 + +#### reuse_addr + +!!! question "自 sing-box 1.12.0 起" + +重用监听地址。 + +#### netns + +!!! question "自 sing-box 1.12.0 起" + +!!! quote "" + + 仅支持 Linux。 + +设置网络命名空间,名称或路径。 + +#### tcp_fast_open + +启用 TCP Fast Open。 + +#### tcp_multi_path + +!!! warning "" + + 需要 Go 1.21。 + +启用 TCP Multi Path。 + +#### disable_tcp_keep_alive + +!!! question "自 sing-box 1.13.0 起" + +禁用 TCP keep alive。 + +#### tcp_keep_alive + +!!! question "自 sing-box 1.13.0 起" + + 默认值从 `10m` 更改为 `5m`。 + +TCP keep alive 初始周期。 + +默认使用 `5m`。 + +#### tcp_keep_alive_interval + +TCP keep alive 间隔。 + +默认使用 `75s`。 + +#### udp_fragment + +启用 UDP 分段。 + +#### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +#### detour + +如果设置,连接将被转发到指定的入站。 + +需要目标入站支持,参阅 [注入支持](/zh/configuration/inbound/#字段)。 + +#### sniff + +!!! failure "已在 sing-box 1.11.0 废弃" + + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). + +启用协议探测。 + +参阅 [协议探测](/zh/configuration/route/sniff/) + +#### sniff_override_destination + +!!! failure "已在 sing-box 1.11.0 废弃" + + 入站字段已废弃且将在 sing-box 1.12.0 中被移除。 + +用探测出的域名覆盖连接目标地址。 + +如果域名无效(如 Tor),将不生效。 + +#### sniff_timeout + +!!! failure "已在 sing-box 1.11.0 废弃" + + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). + +探测超时时间。 + +默认使用 300ms。 + +#### domain_strategy + +!!! failure "已在 sing-box 1.11.0 废弃" + + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). + +可选值: `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 + +如果设置,请求的域名将在路由之前解析为 IP。 + +如果 `sniff_override_destination` 生效,它的值将作为后备。 + +#### udp_disable_domain_unmapping + +!!! failure "已在 sing-box 1.11.0 废弃" + + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). + +如果启用,对于地址为域的 UDP 代理请求,将在响应中发送原始包地址而不是映射的域。 + +此选项用于兼容不支持接收带有域地址的 UDP 包的客户端,如 Surge。 diff --git a/docs/configuration/shared/multiplex.md b/docs/configuration/shared/multiplex.md new file mode 100644 index 00000000..bf722127 --- /dev/null +++ b/docs/configuration/shared/multiplex.md @@ -0,0 +1,86 @@ +### Inbound + +```json +{ + "enabled": true, + "padding": false, + "brutal": {} +} +``` + +### Outbound + +```json +{ + "enabled": true, + "protocol": "smux", + "max_connections": 4, + "min_streams": 4, + "max_streams": 0, + "padding": false, + "brutal": {} +} +``` + + +### Inbound Fields + +#### enabled + +Enable multiplex support. + +#### padding + +If enabled, non-padded connections will be rejected. + +#### brutal + +See [TCP Brutal](/configuration/shared/tcp-brutal/) for details. + +### Outbound Fields + +#### enabled + +Enable multiplex. + +#### protocol + +Multiplex protocol. + +| Protocol | Description | +|----------|------------------------------------| +| smux | https://github.com/xtaci/smux | +| yamux | https://github.com/hashicorp/yamux | +| h2mux | https://golang.org/x/net/http2 | + +h2mux is used by default. + +#### max_connections + +Maximum connections. + +Conflict with `max_streams`. + +#### min_streams + +Minimum multiplexed streams in a connection before opening a new connection. + +Conflict with `max_streams`. + +#### max_streams + +Maximum multiplexed streams in a connection before opening a new connection. + +Conflict with `max_connections` and `min_streams`. + +#### padding + +!!! info + + Requires sing-box server version 1.3-beta9 or later. + +Enable padding. + +#### brutal + +See [TCP Brutal](/configuration/shared/tcp-brutal/) for details. diff --git a/docs/configuration/shared/multiplex.zh.md b/docs/configuration/shared/multiplex.zh.md new file mode 100644 index 00000000..124fe49b --- /dev/null +++ b/docs/configuration/shared/multiplex.zh.md @@ -0,0 +1,85 @@ +### 入站 + +```json +{ + "enabled": true, + "padding": false, + "brutal": {} +} +``` + +### 出站 + +```json +{ + "enabled": true, + "protocol": "smux", + "max_connections": 4, + "min_streams": 4, + "max_streams": 0, + "padding": false, + "brutal": {} +} +``` + +### 入站字段 + +#### enabled + +启用多路复用支持。 + +#### padding + +如果启用,将拒绝非填充连接。 + +#### brutal + +参阅 [TCP Brutal](/zh/configuration/shared/tcp-brutal/)。 + +### 出站字段 + +#### enabled + +启用多路复用。 + +#### protocol + +多路复用协议 + +| 协议 | 描述 | +|-------|------------------------------------| +| smux | https://github.com/xtaci/smux | +| yamux | https://github.com/hashicorp/yamux | +| h2mux | https://golang.org/x/net/http2 | + +默认使用 h2mux。 + +#### max_connections + +最大连接数量。 + +与 `max_streams` 冲突。 + +#### min_streams + +在打开新连接之前,连接中的最小多路复用流数量。 + +与 `max_streams` 冲突。 + +#### max_streams + +在打开新连接之前,连接中的最大多路复用流数量。 + +与 `max_connections` 和 `min_streams` 冲突。 + +#### padding + +!!! info + + 需要 sing-box 服务器版本 1.3-beta9 或更高。 + +启用填充。 + +#### brutal + +参阅 [TCP Brutal](/zh/configuration/shared/tcp-brutal/)。 \ No newline at end of file diff --git a/docs/configuration/shared/neighbor.md b/docs/configuration/shared/neighbor.md new file mode 100644 index 00000000..c67d995e --- /dev/null +++ b/docs/configuration/shared/neighbor.md @@ -0,0 +1,49 @@ +--- +icon: material/lan +--- + +# Neighbor Resolution + +Match LAN devices by MAC address and hostname using +[`source_mac_address`](/configuration/route/rule/#source_mac_address) and +[`source_hostname`](/configuration/route/rule/#source_hostname) rule items. + +Neighbor resolution is automatically enabled when these rule items exist. +Use [`route.find_neighbor`](/configuration/route/#find_neighbor) to force enable it for logging without rules. + +## Linux + +Works natively. No special setup required. + +Hostname resolution requires DHCP lease files, +automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea). +Custom paths can be set via [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files). + +## Android + +!!! quote "" + + Only supported in graphical clients. + +Requires Android 11 or above and ROOT. + +Must use [VPNHotspot](https://github.com/Mygod/VPNHotspot) to share the VPN connection. +ROM built-in features like "Use VPN for connected devices" can share VPN +but cannot provide MAC address or hostname information. + +Set **IP Masquerade Mode** to **None** in VPNHotspot settings. + +Only route/DNS rules are supported. TUN include/exclude routes are not supported. + +### Hostname Visibility + +Hostname is only visible in sing-box if it is visible in VPNHotspot. +For Apple devices, change **Private Wi-Fi Address** from **Rotating** to **Fixed** in the Wi-Fi settings +of the connected network. Non-Apple devices are always visible. + +## macOS + +Requires the standalone version (macOS system extension). +The App Store version can share the VPN as a hotspot but does not support MAC address or hostname reading. + +See [VPN Hotspot](/manual/misc/vpn-hotspot/#macos) for Internet Sharing setup. diff --git a/docs/configuration/shared/neighbor.zh.md b/docs/configuration/shared/neighbor.zh.md new file mode 100644 index 00000000..96297fcb --- /dev/null +++ b/docs/configuration/shared/neighbor.zh.md @@ -0,0 +1,49 @@ +--- +icon: material/lan +--- + +# 邻居解析 + +通过 +[`source_mac_address`](/configuration/route/rule/#source_mac_address) 和 +[`source_hostname`](/configuration/route/rule/#source_hostname) 规则项匹配局域网设备的 MAC 地址和主机名。 + +当这些规则项存在时,邻居解析自动启用。 +使用 [`route.find_neighbor`](/configuration/route/#find_neighbor) 可在没有规则时强制启用以输出日志。 + +## Linux + +原生支持,无需特殊设置。 + +主机名解析需要 DHCP 租约文件, +自动从常见 DHCP 服务器(dnsmasq、odhcpd、ISC dhcpd、Kea)检测。 +可通过 [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files) 设置自定义路径。 + +## Android + +!!! quote "" + + 仅在图形客户端中支持。 + +需要 Android 11 或以上版本和 ROOT。 + +必须使用 [VPNHotspot](https://github.com/Mygod/VPNHotspot) 共享 VPN 连接。 +ROM 自带的「通过 VPN 共享连接」等功能可以共享 VPN, +但无法提供 MAC 地址或主机名信息。 + +在 VPNHotspot 设置中将 **IP 遮掩模式** 设为 **无**。 + +仅支持路由/DNS 规则。不支持 TUN 的 include/exclude 路由。 + +### 设备可见性 + +MAC 地址和主机名仅在 VPNHotspot 中可见时 sing-box 才能读取。 +对于 Apple 设备,需要在所连接网络的 Wi-Fi 设置中将**私有无线局域网地址**从**轮替**改为**固定**。 +非 Apple 设备始终可见。 + +## macOS + +需要独立版本(macOS 系统扩展)。 +App Store 版本可以共享 VPN 热点但不支持 MAC 地址或主机名读取。 + +参阅 [VPN 热点](/manual/misc/vpn-hotspot/#macos) 了解互联网共享设置。 diff --git a/docs/configuration/shared/pre-match.md b/docs/configuration/shared/pre-match.md new file mode 100644 index 00000000..a0faf577 --- /dev/null +++ b/docs/configuration/shared/pre-match.md @@ -0,0 +1,50 @@ +--- +icon: material/new-box +--- + +# Pre-match + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [bypass](#bypass) + +Pre-match is rule matching that runs before the connection is established. + +### How it works + +When TUN receives a connection request, the connection has not yet been established, +so no connection data can be read. In this phase, sing-box runs the routing rules in pre-match mode. + +Since connection data is unavailable, only actions that do not require connection data can be executed. +When a rule matches an action that requires an established connection, pre-match stops at that rule. + +### Supported actions + +#### reject + +Reject with TCP RST / ICMP unreachable. + +See [reject](/configuration/route/rule_action/#reject) for details. + +#### route + +Route ICMP connections to the specified outbound for direct reply. + +See [route](/configuration/route/rule_action/#route) for details. + +#### bypass + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux with `auto_redirect` enabled. + +Bypass sing-box and connect directly at kernel level. + +If `outbound` is not specified, the rule only matches in pre-match from auto redirect, +and will be skipped in other contexts. + +For all other contexts, bypass with `outbound` behaves like `route` action. + +See [bypass](/configuration/route/rule_action/#bypass) for details. diff --git a/docs/configuration/shared/pre-match.zh.md b/docs/configuration/shared/pre-match.zh.md new file mode 100644 index 00000000..06d78f10 --- /dev/null +++ b/docs/configuration/shared/pre-match.zh.md @@ -0,0 +1,47 @@ +--- +icon: material/new-box +--- + +# 预匹配 + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [bypass](#bypass) + +预匹配是在连接建立之前运行的规则匹配。 + +### 工作原理 + +当 TUN 收到连接请求时,连接尚未建立,因此无法读取连接数据。在此阶段,sing-box 在预匹配模式下运行路由规则。 + +由于连接数据不可用,只有不需要连接数据的动作才能执行。当规则匹配到需要已建立连接的动作时,预匹配将在该规则处停止。 + +### 支持的动作 + +#### reject + +以 TCP RST / ICMP 不可达拒绝。 + +详情参阅 [reject](/zh/configuration/route/rule_action/#reject)。 + +#### route + +将 ICMP 连接路由到指定出站以直接回复。 + +详情参阅 [route](/zh/configuration/route/rule_action/#route)。 + +#### bypass + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux,且需要启用 `auto_redirect`。 + +在内核层面绕过 sing-box 直接连接。 + +如果未指定 `outbound`,规则仅在来自 auto redirect 的预匹配中匹配,在其他场景中将被跳过。 + +对于其他所有场景,指定了 `outbound` 的 bypass 行为与 `route` 相同。 + +详情参阅 [bypass](/zh/configuration/route/rule_action/#bypass)。 diff --git a/docs/configuration/shared/tcp-brutal.md b/docs/configuration/shared/tcp-brutal.md new file mode 100644 index 00000000..d248a463 --- /dev/null +++ b/docs/configuration/shared/tcp-brutal.md @@ -0,0 +1,28 @@ +### Server Requirements + +* Linux +* `brutal` congestion control algorithm kernel module installed + +See [tcp-brutal](https://github.com/apernet/tcp-brutal) for details. + +### Structure + +```json +{ + "enabled": true, + "up_mbps": 100, + "down_mbps": 100 +} +``` + +### Fields + +#### enabled + +Enable TCP Brutal congestion control algorithm。 + +#### up_mbps, down_mbps + +==Required== + +Upload and download bandwidth, in Mbps. \ No newline at end of file diff --git a/docs/configuration/shared/tcp-brutal.zh.md b/docs/configuration/shared/tcp-brutal.zh.md new file mode 100644 index 00000000..efb8c51f --- /dev/null +++ b/docs/configuration/shared/tcp-brutal.zh.md @@ -0,0 +1,28 @@ +### 服务器要求 + +* Linux +* `brutal` 拥塞控制算法内核模块已安装 + +参阅 [tcp-brutal](https://github.com/apernet/tcp-brutal)。 + +### 结构 + +```json +{ + "enabled": true, + "up_mbps": 100, + "down_mbps": 100 +} +``` + +### 字段 + +#### enabled + +启用 TCP Brutal 拥塞控制算法。 + +#### up_mbps, down_mbps + +==必填== + +上传和下载带宽,以 Mbps 为单位。 diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md new file mode 100644 index 00000000..518b2f91 --- /dev/null +++ b/docs/configuration/shared/tls.md @@ -0,0 +1,705 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [certificate_provider](#certificate_provider) + :material-delete-clock: [acme](#acme-fields) + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: [kernel_tx](#kernel_tx) + :material-plus: [kernel_rx](#kernel_rx) + :material-plus: [curve_preferences](#curve_preferences) + :material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256) + :material-plus: [client_certificate](#client_certificate) + :material-plus: [client_certificate_path](#client_certificate_path) + :material-plus: [client_key](#client_key) + :material-plus: [client_key_path](#client_key_path) + :material-plus: [client_authentication](#client_authentication) + :material-plus: [client_certificate_public_key_sha256](#client_certificate_public_key_sha256) + :material-plus: [ech.query_server_name](#query_server_name) + +!!! quote "Changes in sing-box 1.12.0" + + :material-plus: [fragment](#fragment) + :material-plus: [fragment_fallback_delay](#fragment_fallback_delay) + :material-plus: [record_fragment](#record_fragment) + :material-delete-clock: [ech.pq_signature_schemes_enabled](#pq_signature_schemes_enabled) + :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled) + +!!! quote "Changes in sing-box 1.10.0" + + :material-alert-decagram: [utls](#utls) + +### Inbound + +```json +{ + "enabled": true, + "server_name": "", + "alpn": [], + "min_version": "", + "max_version": "", + "cipher_suites": [], + "curve_preferences": [], + "certificate": [], + "certificate_path": "", + "client_authentication": "", + "client_certificate": [], + "client_certificate_path": [], + "client_certificate_public_key_sha256": [], + "key": [], + "key_path": "", + "kernel_tx": false, + "kernel_rx": false, + "certificate_provider": "", + + // Deprecated + + "acme": { + "domain": [], + "data_directory": "", + "default_server_name": "", + "email": "", + "provider": "", + "disable_http_challenge": false, + "disable_tls_alpn_challenge": false, + "alternative_http_port": 0, + "alternative_tls_port": 0, + "external_account": { + "key_id": "", + "mac_key": "" + }, + "dns01_challenge": {} + }, + "ech": { + "enabled": false, + "key": [], + "key_path": "", + + // Deprecated + + "pq_signature_schemes_enabled": false, + "dynamic_record_sizing_disabled": false + }, + "reality": { + "enabled": false, + "handshake": { + "server": "google.com", + "server_port": 443, + + ... // Dial Fields + }, + "private_key": "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + "short_id": [ + "0123456789abcdef" + ], + "max_time_difference": "1m" + } +} +``` + +### Outbound + +```json +{ + "enabled": true, + "disable_sni": false, + "server_name": "", + "insecure": false, + "alpn": [], + "min_version": "", + "max_version": "", + "cipher_suites": [], + "curve_preferences": [], + "certificate": "", + "certificate_path": "", + "certificate_public_key_sha256": [], + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "fragment": false, + "fragment_fallback_delay": "", + "record_fragment": false, + "ech": { + "enabled": false, + "config": [], + "config_path": "", + "query_server_name": "", + + // Deprecated + "pq_signature_schemes_enabled": false, + "dynamic_record_sizing_disabled": false + }, + "utls": { + "enabled": false, + "fingerprint": "" + }, + "reality": { + "enabled": false, + "public_key": "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + "short_id": "0123456789abcdef" + } +} +``` + +TLS version values: + +* `1.0` +* `1.1` +* `1.2` +* `1.3` + +Cipher suite values: + +* `TLS_RSA_WITH_AES_128_CBC_SHA` +* `TLS_RSA_WITH_AES_256_CBC_SHA` +* `TLS_RSA_WITH_AES_128_GCM_SHA256` +* `TLS_RSA_WITH_AES_256_GCM_SHA384` +* `TLS_AES_128_GCM_SHA256` +* `TLS_AES_256_GCM_SHA384` +* `TLS_CHACHA20_POLY1305_SHA256` +* `TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA` +* `TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA` +* `TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA` +* `TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA` +* `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` +* `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` +* `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` +* `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` +* `TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256` +* `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### enabled + +Enable TLS. + +#### disable_sni + +==Client only== + +Do not send server name in ClientHello. + +#### server_name + +Used to verify the hostname on the returned certificates unless insecure is given. + +It is also included in the client's handshake to support virtual hosting unless it is an IP address. + +#### insecure + +==Client only== + +Accepts any server certificate. + +#### alpn + +List of supported application level protocols, in order of preference. + +If both peers support ALPN, the selected protocol will be one from this list, and the connection will fail if there is +no mutually supported protocol. + +See [Application-Layer Protocol Negotiation](https://en.wikipedia.org/wiki/Application-Layer_Protocol_Negotiation). + +#### min_version + +The minimum TLS version that is acceptable. + +By default, TLS 1.2 is currently used as the minimum when acting as a +client, and TLS 1.0 when acting as a server. + +#### max_version + +The maximum TLS version that is acceptable. + +By default, the maximum version is currently TLS 1.3. + +#### cipher_suites + +List of enabled TLS 1.0–1.2 cipher suites. The order of the list is ignored. +Note that TLS 1.3 cipher suites are not configurable. + +If empty, a safe default list is used. The default cipher suites might change over time. + +#### curve_preferences + +!!! question "Since sing-box 1.13.0" + +Set of supported key exchange mechanisms. The order of the list is ignored, and key exchange mechanisms are chosen +from this list using an internal preference order by Golang. + +Available values, also the default list: + +* `P256` +* `P384` +* `P521` +* `X25519` +* `X25519MLKEM768` + +#### certificate + +Server certificates chain line array, in PEM format. + +#### certificate_path + +!!! note "" + + Will be automatically reloaded if file modified. + +The path to server certificate chain, in PEM format. + + +#### certificate_public_key_sha256 + +!!! question "Since sing-box 1.13.0" + +==Client only== + +List of SHA-256 hashes of server certificate public keys, in base64 format. + +To generate the SHA-256 hash for a certificate's public key, use the following commands: + +```bash +# For a certificate file +openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 + +# For a certificate from a remote server +echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 +``` + +#### client_certificate + +!!! question "Since sing-box 1.13.0" + +==Client only== + +Client certificate chain line array, in PEM format. + +#### client_certificate_path + +!!! question "Since sing-box 1.13.0" + +==Client only== + +The path to client certificate chain, in PEM format. + +#### client_key + +!!! question "Since sing-box 1.13.0" + +==Client only== + +Client private key line array, in PEM format. + +#### client_key_path + +!!! question "Since sing-box 1.13.0" + +==Client only== + +The path to client private key, in PEM format. + +#### key + +==Server only== + +The server private key line array, in PEM format. + +#### key_path + +==Server only== + +!!! note "" + + Will be automatically reloaded if file modified. + +The path to the server private key, in PEM format. + +#### client_authentication + +!!! question "Since sing-box 1.13.0" + +==Server only== + +The type of client authentication to use. + +Available values: + +* `no` (default) +* `request` +* `require-any` +* `verify-if-given` +* `require-and-verify` + +One of `client_certificate`, `client_certificate_path`, or `client_certificate_public_key_sha256` is required +if this option is set to `verify-if-given`, or `require-and-verify`. + +#### client_certificate + +!!! question "Since sing-box 1.13.0" + +==Server only== + +Client certificate chain line array, in PEM format. + +#### client_certificate_path + +!!! question "Since sing-box 1.13.0" + +==Server only== + +!!! note "" + + Will be automatically reloaded if file modified. + +List of path to client certificate chain, in PEM format. + +#### client_certificate_public_key_sha256 + +!!! question "Since sing-box 1.13.0" + +==Server only== + +List of SHA-256 hashes of client certificate public keys, in base64 format. + +To generate the SHA-256 hash for a certificate's public key, use the following commands: + +```bash +# For a certificate file +openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 + +# For a certificate from a remote server +echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 +``` + +#### kernel_tx + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux 5.1+, use a newer kernel if possible. + +!!! quote "" + + Only TLS 1.3 is supported. + +!!! warning "" + + kTLS TX may only improve performance when `splice(2)` is available (both ends must be TCP or TLS without additional protocols after handshake); otherwise, it will definitely degrade performance. + +Enable kernel TLS transmit support. + +#### kernel_rx + +!!! question "Since sing-box 1.13.0" + +!!! quote "" + + Only supported on Linux 5.1+, use a newer kernel if possible. + +!!! quote "" + + Only TLS 1.3 is supported. + +!!! failure "" + + kTLS RX will definitely degrade performance even if `splice(2)` is in use, so enabling it is not recommended. + +Enable kernel TLS receive support. + +#### certificate_provider + +!!! question "Since sing-box 1.14.0" + +==Server only== + +A string or an object. + +When string, the tag of a shared [Certificate Provider](/configuration/shared/certificate-provider/). + +When object, an inline certificate provider. See [Certificate Provider](/configuration/shared/certificate-provider/) for available types and fields. + +## Custom TLS support + +!!! info "QUIC support" + + Only ECH is supported in QUIC. + +#### utls + +==Client only== + +!!! failure "Not Recommended" + + uTLS has had repeated fingerprinting vulnerabilities discovered by researchers. + + uTLS is a Go library that attempts to imitate browser TLS fingerprints by copying + ClientHello structure. However, browsers use completely different TLS stacks + (Chrome uses BoringSSL, Firefox uses NSS) with distinct implementation behaviors + that cannot be replicated by simply copying the handshake format, making detection possible. + Additionally, the library lacks active maintenance and has poor code quality, + making it unsuitable for censorship circumvention. + + For TLS fingerprint resistance, use [NaiveProxy](/configuration/inbound/naive/) instead. + +uTLS is a fork of "crypto/tls", which provides ClientHello fingerprinting resistance. + +Available fingerprint values: + +!!! warning "Removed since sing-box 1.10.0" + + Some legacy chrome fingerprints have been removed and will fallback to chrome: + + :material-close: chrome_psk + :material-close: chrome_psk_shuffle + :material-close: chrome_padding_psk_shuffle + :material-close: chrome_pq + :material-close: chrome_pq_psk + +* chrome +* firefox +* edge +* safari +* 360 +* qq +* ios +* android +* random +* randomized + +Chrome fingerprint will be used if empty. + +### ECH Fields + +ECH (Encrypted Client Hello) is a TLS extension that allows a client to encrypt the first part of its ClientHello +message. + +The ECH key and configuration can be generated by `sing-box generate ech-keypair`. + +#### pq_signature_schemes_enabled + +!!! failure "Deprecated in sing-box 1.12.0" + + `pq_signature_schemes_enabled` is deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0. + +Enable support for post-quantum peer certificate signature schemes. + +#### dynamic_record_sizing_disabled + +!!! failure "Deprecated in sing-box 1.12.0" + + `dynamic_record_sizing_disabled` is deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0. + +Disables adaptive sizing of TLS records. + +When true, the largest possible TLS record size is always used. +When false, the size of TLS records may be adjusted in an attempt to improve latency. + +#### key + +==Server only== + +ECH key line array, in PEM format. + +#### key_path + +==Server only== + +!!! note "" + + Will be automatically reloaded if file modified. + +The path to ECH key, in PEM format. + +#### config + +==Client only== + +ECH configuration line array, in PEM format. + +If empty, load from DNS will be attempted. + +#### config_path + +==Client only== + +The path to ECH configuration, in PEM format. + +If empty, load from DNS will be attempted. + +#### query_server_name + +!!! question "Since sing-box 1.13.0" + +==Client only== + +Overrides the domain name used for ECH HTTPS record queries. + +If empty, `server_name` is used for queries. + +#### fragment + +!!! question "Since sing-box 1.12.0" + +==Client only== + +Fragment TLS handshakes to bypass firewalls. + +This feature is intended to circumvent simple firewalls based on **plaintext packet matching**, +and should not be used to circumvent real censorship. + +Due to poor performance, try `record_fragment` first, and only apply to server names known to be blocked. + +On Linux, Apple platforms, (administrator privileges required) Windows, +the wait time can be automatically detected. Otherwise, it will fall back to +waiting for a fixed time specified by `fragment_fallback_delay`. + +In addition, if the actual wait time is less than 20ms, it will also fall back to waiting for a fixed time, +because the target is considered to be local or behind a transparent proxy. + +#### fragment_fallback_delay + +!!! question "Since sing-box 1.12.0" + +==Client only== + +The fallback value used when TLS segmentation cannot automatically determine the wait time. + +`500ms` is used by default. + +#### record_fragment + +!!! question "Since sing-box 1.12.0" + +==Client only== + +Fragment TLS handshake into multiple TLS records to bypass firewalls. + +### ACME Fields + +!!! failure "Deprecated in sing-box 1.14.0" + + Inline ACME options are deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0, check [Migration](/migration/#migrate-inline-acme-to-certificate-provider). + +#### domain + +List of domain. + +ACME will be disabled if empty. + +#### data_directory + +The directory to store ACME data. + +`$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic` will be used if empty. + +#### default_server_name + +Server name to use when choosing a certificate if the ClientHello's ServerName field is empty. + +#### email + +The email address to use when creating or selecting an existing ACME server account + +#### provider + +The ACME CA provider to use. + +| Value | Provider | +|-------------------------|---------------| +| `letsencrypt (default)` | Let's Encrypt | +| `zerossl` | ZeroSSL | +| `https://...` | Custom | + +#### disable_http_challenge + +Disable all HTTP challenges. + +#### disable_tls_alpn_challenge + +Disable all TLS-ALPN challenges + +#### alternative_http_port + +The alternate port to use for the ACME HTTP challenge; if non-empty, this port will be used instead of 80 to spin up a +listener for the HTTP challenge. + +#### alternative_tls_port + +The alternate port to use for the ACME TLS-ALPN challenge; the system must forward 443 to this port for challenge to +succeed. + +#### external_account + +EAB (External Account Binding) contains information necessary to bind or map an ACME account to some other account known +by the CA. + +External account bindings are "used to associate an ACME account with an existing account in a non-ACME system, such as +a CA customer database. + +To enable ACME account binding, the CA operating the ACME server needs to provide the ACME client with a MAC key and a +key identifier, using some mechanism outside of ACME. §7.3.4 + +#### external_account.key_id + +The key identifier. + +#### external_account.mac_key + +The MAC key. + +#### dns01_challenge + +ACME DNS01 challenge field. If configured, other challenge methods will be disabled. + +See [DNS01 Challenge Fields](/configuration/shared/dns01_challenge/) for details. + +### Reality Fields + +#### handshake + +==Server only== + +==Required== + +Handshake server address and [Dial Fields](/configuration/shared/dial/). + +#### private_key + +==Server only== + +==Required== + +Private key, generated by `sing-box generate reality-keypair`. + +#### public_key + +==Client only== + +==Required== + +Public key, generated by `sing-box generate reality-keypair`. + +#### short_id + +==Required== + +A hexadecimal string with zero to eight digits. + +#### max_time_difference + +==Server only== + +The maximum time difference between the server and the client. + +Check disabled if empty. diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md new file mode 100644 index 00000000..56b90d33 --- /dev/null +++ b/docs/configuration/shared/tls.zh.md @@ -0,0 +1,695 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [certificate_provider](#certificate_provider) + :material-delete-clock: [acme](#acme-字段) + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: [kernel_tx](#kernel_tx) + :material-plus: [kernel_rx](#kernel_rx) + :material-plus: [curve_preferences](#curve_preferences) + :material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256) + :material-plus: [client_certificate](#client_certificate) + :material-plus: [client_certificate_path](#client_certificate_path) + :material-plus: [client_key](#client_key) + :material-plus: [client_key_path](#client_key_path) + :material-plus: [client_authentication](#client_authentication) + :material-plus: [client_certificate_public_key_sha256](#client_certificate_public_key_sha256) + :material-plus: [ech.query_server_name](#query_server_name) + +!!! quote "sing-box 1.12.0 中的更改" + + :material-plus: [fragment](#fragment) + :material-plus: [fragment_fallback_delay](#fragment_fallback_delay) + :material-plus: [record_fragment](#record_fragment) + :material-delete-clock: [ech.pq_signature_schemes_enabled](#pq_signature_schemes_enabled) + :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled) + +!!! quote "sing-box 1.10.0 中的更改" + + :material-alert-decagram: [utls](#utls) + +### 入站 + +```json +{ + "enabled": true, + "server_name": "", + "alpn": [], + "min_version": "", + "max_version": "", + "cipher_suites": [], + "curve_preferences": [], + "certificate": [], + "certificate_path": "", + "client_authentication": "", + "client_certificate": [], + "client_certificate_path": [], + "client_certificate_public_key_sha256": [], + "key": [], + "key_path": "", + "kernel_tx": false, + "kernel_rx": false, + "certificate_provider": "", + + // 废弃的 + + "acme": { + "domain": [], + "data_directory": "", + "default_server_name": "", + "email": "", + "provider": "", + "disable_http_challenge": false, + "disable_tls_alpn_challenge": false, + "alternative_http_port": 0, + "alternative_tls_port": 0, + "external_account": { + "key_id": "", + "mac_key": "" + }, + "dns01_challenge": {} + }, + "ech": { + "enabled": false, + "key": [], + "key_path": "", + + // 废弃的 + + "pq_signature_schemes_enabled": false, + "dynamic_record_sizing_disabled": false + }, + "reality": { + "enabled": false, + "handshake": { + "server": "google.com", + "server_port": 443, + + ... // 拨号字段 + }, + "private_key": "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + "short_id": [ + "0123456789abcdef" + ], + "max_time_difference": "1m" + } +} +``` + +### 出站 + +```json +{ + "enabled": true, + "disable_sni": false, + "server_name": "", + "insecure": false, + "alpn": [], + "min_version": "", + "max_version": "", + "cipher_suites": [], + "curve_preferences": [], + "certificate": "", + "certificate_path": "", + "certificate_public_key_sha256": [], + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "fragment": false, + "fragment_fallback_delay": "", + "record_fragment": false, + "ech": { + "enabled": false, + "config": [], + "config_path": "", + "query_server_name": "", + + // 废弃的 + "pq_signature_schemes_enabled": false, + "dynamic_record_sizing_disabled": false + }, + "utls": { + "enabled": false, + "fingerprint": "" + }, + "reality": { + "enabled": false, + "public_key": "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + "short_id": "0123456789abcdef" + } +} +``` + +TLS 版本值: + +* `1.0` +* `1.1` +* `1.2` +* `1.3` + +密码套件值: + +* `TLS_RSA_WITH_AES_128_CBC_SHA` +* `TLS_RSA_WITH_AES_256_CBC_SHA` +* `TLS_RSA_WITH_AES_128_GCM_SHA256` +* `TLS_RSA_WITH_AES_256_GCM_SHA384` +* `TLS_AES_128_GCM_SHA256` +* `TLS_AES_256_GCM_SHA384` +* `TLS_CHACHA20_POLY1305_SHA256` +* `TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA` +* `TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA` +* `TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA` +* `TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA` +* `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` +* `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` +* `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` +* `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` +* `TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256` +* `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +### 字段 + +#### enabled + +启用 TLS + +#### disable_sni + +==仅客户端== + +不要在 ClientHello 中发送服务器名称. + +#### server_name + +用于验证返回证书上的主机名,除非设置不安全。 + +它还包含在 ClientHello 中以支持虚拟主机,除非它是 IP 地址。 + +#### insecure + +==仅客户端== + +接受任何服务器证书。 + +#### alpn + +支持的应用层协议协商列表,按优先顺序排列。 + +如果两个对等点都支持 ALPN,则选择的协议将是此列表中的一个,如果没有相互支持的协议则连接将失败。 + +参阅 [Application-Layer Protocol Negotiation](https://en.wikipedia.org/wiki/Application-Layer_Protocol_Negotiation)。 + +#### min_version + +可接受的最低 TLS 版本。 + +默认情况下,当前使用 TLS 1.2 作为客户端的最低要求。作为服务器时使用 TLS 1.0。 + +#### max_version + +可接受的最大 TLS 版本。 + +默认情况下,当前最高版本为 TLS 1.3。 + +#### cipher_suites + +启用的 TLS 1.0–1.2 密码套件列表。列表的顺序被忽略。请注意,TLS 1.3 的密码套件是不可配置的。 + +如果为空,则使用安全的默认列表。默认密码套件可能会随着时间的推移而改变。 + +#### curve_preferences + +!!! question "自 sing-box 1.13.0 起" + +支持的密钥交换机制集合。列表的顺序被忽略,密钥交换机制通过 Golang 的内部偏好顺序从此列表中选择。 + +可用值,同时也是默认列表: + +* `P256` +* `P384` +* `P521` +* `X25519` +* `X25519MLKEM768` + +#### certificate + +服务器证书链行数组,PEM 格式。 + +#### certificate_path + +!!! note "" + + 文件更改时将自动重新加载。 + +服务器证书链路径,PEM 格式。 + +#### certificate_public_key_sha256 + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +服务器证书公钥的 SHA-256 哈希列表,base64 格式。 + +要生成证书公钥的 SHA-256 哈希,请使用以下命令: + +```bash +# 对于证书文件 +openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 + +# 对于远程服务器的证书 +echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 +``` + +#### client_certificate + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端证书链行数组,PEM 格式。 + +#### client_certificate_path + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端证书链路径,PEM 格式。 + +#### client_key + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端私钥行数组,PEM 格式。 + +#### client_key_path + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端私钥路径,PEM 格式。 + +#### key + +==仅服务器== + +!!! note "" + + 文件更改时将自动重新加载。 + +服务器 PEM 私钥行数组。 + +#### key_path + +==仅服务器== + +!!! note "" + + 文件更改时将自动重新加载。 + +服务器私钥路径,PEM 格式。 + +#### client_authentication + +!!! question "自 sing-box 1.13.0 起" + +==仅服务器== + +要使用的客户端身份验证类型。 + +可用值: + +* `no`(默认) +* `request` +* `require-any` +* `verify-if-given` +* `require-and-verify` + +如果此选项设置为 `verify-if-given` 或 `require-and-verify`, +则需要 `client_certificate`、`client_certificate_path` 或 `client_certificate_public_key_sha256` 中的一个。 + +#### client_certificate + +!!! question "自 sing-box 1.13.0 起" + +==仅服务器== + +客户端证书链行数组,PEM 格式。 + +#### client_certificate_path + +!!! question "自 sing-box 1.13.0 起" + +==仅服务器== + +!!! note "" + + 文件更改时将自动重新加载。 + +客户端证书链路径列表,PEM 格式。 + +#### client_certificate_public_key_sha256 + +!!! question "自 sing-box 1.13.0 起" + +==仅服务器== + +客户端证书公钥的 SHA-256 哈希列表,base64 格式。 + +要生成证书公钥的 SHA-256 哈希,请使用以下命令: + +```bash +# 对于证书文件 +openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 + +# 对于远程服务器的证书 +echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 +``` + +#### kernel_tx + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux 5.1+,如果可能,使用较新的内核。 + +!!! quote "" + + 仅支持 TLS 1.3。 + +!!! warning "" + + kTLS TX 仅当 `splice(2)` 可用时(两端经过握手后必须为没有附加协议的 TCP 或 TLS)才能提高性能;否则肯定会降低性能。 + +启用内核 TLS 发送支持。 + +#### kernel_rx + +!!! question "自 sing-box 1.13.0 起" + +!!! quote "" + + 仅支持 Linux 5.1+,如果可能,使用较新的内核。 + +!!! quote "" + + 仅支持 TLS 1.3。 + +!!! failure "" + + 即使使用 `splice(2)`,kTLS RX 也肯定会降低性能,因此不建议启用。 + +启用内核 TLS 接收支持。 + +#### certificate_provider + +!!! question "自 sing-box 1.14.0 起" + +==仅服务器== + +字符串或对象。 + +为字符串时,共享[证书提供者](/zh/configuration/shared/certificate-provider/)的标签。 + +为对象时,内联的证书提供者。可用类型和字段参阅[证书提供者](/zh/configuration/shared/certificate-provider/)。 + +## 自定义 TLS 支持 + +!!! info "QUIC 支持" + + 只有 ECH 在 QUIC 中被支持. + +#### utls + +==仅客户端== + +!!! failure "不推荐" + + uTLS 已被研究人员多次发现其指纹可被识别的漏洞。 + + uTLS 是一个试图通过复制 ClientHello 结构来模仿浏览器 TLS 指纹的 Go 库。 + 然而,浏览器使用完全不同的 TLS 实现(Chrome 使用 BoringSSL,Firefox 使用 NSS), + 其实现行为无法通过简单复制握手格式来复现,其行为细节必然存在差异,使得检测成为可能。 + 此外,此库缺乏积极维护,且代码质量较差,不建议用于反审查场景。 + + 如需 TLS 指纹抵抗,请改用 [NaiveProxy](/zh/configuration/inbound/naive/)。 + +uTLS 是 "crypto/tls" 的一个分支,它提供了 ClientHello 指纹识别阻力。 + +可用的指纹值: + +!!! warning "已在 sing-box 1.10.0 移除" + + 一些旧 chrome 指纹已被删除,并将会退到 chrome: + + :material-close: chrome_psk + :material-close: chrome_psk_shuffle + :material-close: chrome_padding_psk_shuffle + :material-close: chrome_pq + :material-close: chrome_pq_psk + +* chrome +* firefox +* edge +* safari +* 360 +* qq +* ios +* android +* random +* randomized + +默认使用 chrome 指纹。 + +### ECH 字段 + +ECH (Encrypted Client Hello) 是一个 TLS 扩展,它允许客户端加密其 ClientHello 的第一部分信息。 + +ECH 密钥和配置可以通过 `sing-box generate ech-keypair` 生成。 + +#### pq_signature_schemes_enabled + +!!! failure "已在 sing-box 1.12.0 废弃" + + `pq_signature_schemes_enabled` 已在 sing-box 1.12.0 废弃且已在 sing-box 1.13.0 中被移除。 + +启用对后量子对等证书签名方案的支持。 + +#### dynamic_record_sizing_disabled + +!!! failure "已在 sing-box 1.12.0 废弃" + + `dynamic_record_sizing_disabled` 已在 sing-box 1.12.0 废弃且已在 sing-box 1.13.0 中被移除。 + +禁用 TLS 记录的自适应大小调整。 + +当为 true 时,总是使用最大可能的 TLS 记录大小。 +当为 false 时,可能会调整 TLS 记录的大小以尝试改善延迟。 + +#### key + +==仅服务器== + +ECH 密钥行数组,PEM 格式。 + +#### key_path + +==仅服务器== + +!!! note "" + + 文件更改时将自动重新加载。 + +ECH 密钥路径,PEM 格式。 + +#### config + +==仅客户端== + +ECH 配置行数组,PEM 格式。 + +如果为空,将尝试从 DNS 加载。 + +#### config_path + +==仅客户端== + +ECH 配置路径,PEM 格式。 + +如果为空,将尝试从 DNS 加载。 + +#### query_server_name + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +覆盖用于 ECH HTTPS 记录查询的域名。 + +如果为空,使用 `server_name` 查询。 + +#### fragment + +!!! question "自 sing-box 1.12.0 起" + +==仅客户端== + +通过分段 TLS 握手数据包来绕过防火墙。 + +此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真正的审查。 + +由于性能不佳,请首先尝试 `record_fragment`,且仅应用于已知被阻止的服务器名称。 + +在 Linux、Apple 平台和(需要管理员权限的)Windows 系统上, +可以自动检测等待时间。否则,将回退到 +等待 `fragment_fallback_delay` 指定的固定时间。 + +此外,如果实际等待时间少于 20ms,也会回退到等待固定时间, +因为目标被认为是本地的或在透明代理后面。 + +#### fragment_fallback_delay + +!!! question "自 sing-box 1.12.0 起" + +==仅客户端== + +当 TLS 分段无法自动确定等待时间时使用的回退值。 + +默认使用 `500ms`。 + +#### record_fragment + +!!! question "自 sing-box 1.12.0 起" + +==仅客户端== + +将 TLS 握手分段为多个 TLS 记录以绕过防火墙。 + +### ACME 字段 + +!!! failure "已在 sing-box 1.14.0 废弃" + + 内联 ACME 选项已在 sing-box 1.14.0 废弃且将在 sing-box 1.16.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移内联-acme-到证书提供者)。 + +#### domain + +域名列表。 + +如果为空则禁用 ACME。 + +#### data_directory + +ACME 数据存储目录。 + +如果为空则使用 `$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic`。 + +#### default_server_name + +如果 ClientHello 的 ServerName 字段为空,则选择证书时要使用的服务器名称。 + +#### email + +创建或选择现有 ACME 服务器帐户时使用的电子邮件地址。 + +#### provider + +要使用的 ACME CA 供应商。 + +| 值 | 供应商 | +|--------------------|---------------| +| `letsencrypt (默认)` | Let's Encrypt | +| `zerossl` | ZeroSSL | +| `https://...` | 自定义 | + +#### disable_http_challenge + +禁用所有 HTTP 质询。 + +#### disable_tls_alpn_challenge + +禁用所有 TLS-ALPN 质询。 + +#### alternative_http_port + +用于 ACME HTTP 质询的备用端口;如果非空,将使用此端口而不是 80 来启动 HTTP 质询的侦听器。 + +#### alternative_tls_port + +用于 ACME TLS-ALPN 质询的备用端口; 系统必须将 443 转发到此端口以使质询成功。 + +#### external_account + +EAB(外部帐户绑定)包含将 ACME 帐户绑定或映射到 CA 已知的其他帐户所需的信息。 + +外部帐户绑定"用于将 ACME 帐户与非 ACME 系统中的现有帐户相关联,例如 CA 客户数据库。 + +为了启用 ACME 帐户绑定,运行 ACME 服务器的 CA 需要使用 ACME 之外的某种机制向 ACME 客户端提供 MAC 密钥和密钥标识符。§7.3.4 + +#### external_account.key_id + +密钥标识符。 + +#### external_account.mac_key + +MAC 密钥。 + +#### dns01_challenge + +ACME DNS01 验证字段。如果配置,将禁用其他验证方法。 + +参阅 [DNS01 验证字段](/zh/configuration/shared/dns01_challenge/)。 + +### Reality 字段 + +#### handshake + +==仅服务器== + +==必填== + +握手服务器地址和 [拨号参数](/zh/configuration/shared/dial/)。 + +#### private_key + +==仅服务器== + +==必填== + +私钥,由 `sing-box generate reality-keypair` 生成。 + +#### public_key + +==仅客户端== + +==必填== + +公钥,由 `sing-box generate reality-keypair` 生成。 + +#### short_id + +==必填== + +一个零到八位的十六进制字符串。 + +#### max_time_difference + +==仅服务器== + +服务器和客户端之间的最大时间差。 + +如果为空则禁用检查。 diff --git a/docs/configuration/shared/udp-over-tcp.md b/docs/configuration/shared/udp-over-tcp.md new file mode 100644 index 00000000..71a934db --- /dev/null +++ b/docs/configuration/shared/udp-over-tcp.md @@ -0,0 +1,82 @@ +!!! warning "" + + It's a proprietary protocol created by SagerNet, not part of shadowsocks. + +The UDP over TCP protocol is used to transmit UDP packets in TCP. + +### Structure + +```json +{ + "enabled": true, + "version": 2 +} +``` + +!!! info "" + + The structure can be replaced with a boolean value when the version is not specified. + +### Fields + +#### enabled + +Enable the UDP over TCP protocol. + +#### version + +The protocol version, `1` or `2`. + +2 is used by default. + +### Application support + +| Project | UoT v1 | UoT v2 | +|--------------|----------------------|----------------------| +| sing-box | v0 (2022/08/11) | v1.2-beta9 | +| Clash.Meta | v1.12.0 (2022/07/02) | v1.14.3 (2023/03/31) | +| Shadowrocket | v2.2.12 (2022/08/13) | / | + +### Protocol details + +#### Protocol version 1 + +The client requests the magic address to the upper layer proxy protocol to indicate the request: `sp.udp-over-tcp.arpa` + +#### Stream format + +| ATYP | address | port | length | data | +|------|----------|-------|--------|----------| +| u8 | variable | u16be | u16be | variable | + +**ATYP / address / port**: Uses the SOCKS address format, but with different address types: + +| ATYP | Address type | +|--------|--------------| +| `0x00` | IPv4 Address | +| `0x01` | IPv6 Address | +| `0x02` | Domain Name | + +#### Protocol version 2 + +Protocol version 2 uses a new magic address: `sp.v2.udp-over-tcp.arpa` + +##### Request format + +| isConnect | ATYP | address | port | +|-----------|------|----------|-------| +| u8 | u8 | variable | u16be | + +**isConnect**: Set to 1 to indicates that the stream uses the connect format, 0 to disable. + +**ATYP / address / port**: Request destination, uses the SOCKS address format. + +##### Connect stream format + +| length | data | +|--------|----------| +| u16be | variable | + +##### Non-connect stream format + +As the same as the stream format in protocol version 1. \ No newline at end of file diff --git a/docs/configuration/shared/udp-over-tcp.zh.md b/docs/configuration/shared/udp-over-tcp.zh.md new file mode 100644 index 00000000..fec9645e --- /dev/null +++ b/docs/configuration/shared/udp-over-tcp.zh.md @@ -0,0 +1,82 @@ +!!! warning "" + + 这是 SagerNet 创建的专有协议,不是 shadowsocks 的一部分。 + +UDP over TCP 协议用于在 TCP 中传输 UDP 数据包。 + +### 结构 + +```json +{ + "enabled": true, + "version": 2 +} +``` + +!!! info "" + + 当不指定版本时,结构可以用布尔值替换。 + +### 字段 + +#### enabled + +启用 UDP over TCP 协议。 + +#### version + +协议版本,`1` 或 `2`。 + +默认使用 2。 + +### 应用程序支持 + +| 项目 | UoT v1 | UoT v2 | +|--------------|----------------------|----------------------| +| sing-box | v0 (2022/08/11) | v1.2-beta9 | +| Clash.Meta | v1.12.0 (2022/07/02) | v1.14.3 (2023/03/31) | +| Shadowrocket | v2.2.12 (2022/08/13) | / | + +### 协议详情 + +#### 协议版本 1 + +客户端向上层代理协议请求魔法地址以表示请求:`sp.udp-over-tcp.arpa` + +#### 流格式 + +| ATYP | 地址 | 端口 | 长度 | 数据 | +|------|----------|-------|--------|----------| +| u8 | 可变长 | u16be | u16be | 可变长 | + +**ATYP / 地址 / 端口**:使用 SOCKS 地址格式,但使用不同的地址类型: + +| ATYP | 地址类型 | +|--------|-----------| +| `0x00` | IPv4 地址 | +| `0x01` | IPv6 地址 | +| `0x02` | 域名 | + +#### 协议版本 2 + +协议版本 2 使用新的魔法地址:`sp.v2.udp-over-tcp.arpa` + +##### 请求格式 + +| isConnect | ATYP | 地址 | 端口 | +|-----------|------|----------|-------| +| u8 | u8 | 可变长 | u16be | + +**isConnect**:设置为 1 表示流使用连接格式,0 表示禁用。 + +**ATYP / 地址 / 端口**:请求目标,使用 SOCKS 地址格式。 + +##### 连接流格式 + +| 长度 | 数据 | +|--------|----------| +| u16be | 可变长 | + +##### 非连接流格式 + +与协议版本 1 中的流格式相同。 \ No newline at end of file diff --git a/docs/configuration/shared/v2ray-transport.md b/docs/configuration/shared/v2ray-transport.md new file mode 100644 index 00000000..afc33241 --- /dev/null +++ b/docs/configuration/shared/v2ray-transport.md @@ -0,0 +1,229 @@ +V2Ray Transport is a set of private protocols invented by v2ray, and has contaminated the names of other protocols, such +as `trojan-grpc` in clash. + +### Structure + +```json +{ + "type": "" +} +``` + +Available transports: + +* HTTP +* WebSocket +* QUIC +* gRPC +* HTTPUpgrade + +!!! warning "Difference from v2ray-core" + + * No TCP transport, plain HTTP is merged into the HTTP transport. + * No mKCP transport. + * No DomainSocket transport. + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### HTTP + +```json +{ + "type": "http", + "host": [], + "path": "", + "method": "", + "headers": {}, + "idle_timeout": "15s", + "ping_timeout": "15s" +} +``` + +!!! warning "Difference from v2ray-core" + + TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used. + +#### host + +List of host domain. + +The client will choose randomly and the server will verify if not empty. + +#### path + +!!! warning + + V2Ray's documentation says that the path between the server and the client must be consistent, + but the actual code allows the client to add any suffix to the path. + sing-box uses the same behavior as V2Ray, but note that the behavior does not exist in `WebSocket` and `HTTPUpgrade` transport. + +Path of HTTP request. + +The server will verify. + +#### method + +Method of HTTP request. + +The server will verify if not empty. + +#### headers + +Extra headers of HTTP request. + +The server will write in response if not empty. + +#### idle_timeout + +In HTTP2 server: + +Specifies the time until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity. + +In HTTP2 client: + +Specifies the period of time after which a health check will be performed using a ping frame if no frames have been +received on the connection.Please note that a ping response is considered a received frame, so if there is no other +traffic on the connection, the health check will be executed every interval. If the value is zero, no health check will +be performed. + +Zero is used by default. + +#### ping_timeout + +In HTTP2 client: + +Specifies the timeout duration after sending a PING frame, within which a response must be received. +If a response to the PING frame is not received within the specified timeout duration, the connection will be closed. +The default timeout duration is 15 seconds. + +### WebSocket + +```json +{ + "type": "ws", + "path": "", + "headers": {}, + "max_early_data": 0, + "early_data_header_name": "" +} +``` + +#### path + +Path of HTTP request. + +The server will verify. + +#### headers + +Extra headers of HTTP request. + +The server will write in response if not empty. + +#### max_early_data + +Allowed payload size is in the request. Enabled if not zero. + +#### early_data_header_name + +Early data is sent in path instead of header by default. + +To be compatible with Xray-core, set this to `Sec-WebSocket-Protocol`. + +It needs to be consistent with the server. + +### QUIC + +```json +{ + "type": "quic" +} +``` + +!!! warning "Difference from v2ray-core" + + No additional encryption support: + It's basically duplicate encryption. And Xray-core is not compatible with v2ray-core in here. + +### gRPC + +!!! note "" + + standard gRPC has good compatibility but poor performance and is not included by default, see [Installation](/installation/build-from-source/#build-tags). + +```json +{ + "type": "grpc", + "service_name": "TunService", + "idle_timeout": "15s", + "ping_timeout": "15s", + "permit_without_stream": false +} +``` + +#### service_name + +Service name of gRPC. + +#### idle_timeout + +In standard gRPC server/client: + +If the transport doesn't see any activity after a duration of this time, +it pings the client to check if the connection is still active. + +In default gRPC server/client: + +It has the same behavior as the corresponding setting in HTTP transport. + +#### ping_timeout + +In standard gRPC server/client: + +The timeout that after performing a keepalive check, the client will wait for activity. +If no activity is detected, the connection will be closed. + +In default gRPC server/client: + +It has the same behavior as the corresponding setting in HTTP transport. + +#### permit_without_stream + +In standard gRPC client: + +If enabled, the client transport sends keepalive pings even with no active connections. +If disabled, when there are no active connections, `idle_timeout` and `ping_timeout` will be ignored and no keepalive +pings will be sent. + +Disabled by default. + +### HTTPUpgrade + +```json +{ + "type": "httpupgrade", + "host": "", + "path": "", + "headers": {} +} +``` + +#### host + +Host domain. + +The server will verify if not empty. + +#### path + +Path of HTTP request. + +The server will verify. + +#### headers + +Extra headers of HTTP request. + +The server will write in response if not empty. diff --git a/docs/configuration/shared/v2ray-transport.zh.md b/docs/configuration/shared/v2ray-transport.zh.md new file mode 100644 index 00000000..e5ea3ed6 --- /dev/null +++ b/docs/configuration/shared/v2ray-transport.zh.md @@ -0,0 +1,218 @@ +V2Ray Transport 是 v2ray 发明的一组私有协议,并污染了其他协议的名称,如 clash 中的 `trojan-grpc`。 + +### 结构 + +```json +{ + "type": "" +} +``` + +可用的传输协议: + +* HTTP +* WebSocket +* QUIC +* gRPC +* HTTPUpgrade + +!!! warning "与 v2ray-core 的区别" + + * 没有 TCP 传输层, 纯 HTTP 已合并到 HTTP 传输层。 + * 没有 mKCP 传输层。 + * 没有 DomainSocket 传输层。 + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### HTTP + +```json +{ + "type": "http", + "host": [], + "path": "", + "method": "", + "headers": {}, + "idle_timeout": "15s", + "ping_timeout": "15s" +} +``` + +!!! warning "与 v2ray-core 的区别" + + 不强制执行 TLS。如果未配置 TLS,将使用纯 HTTP 1.1。 + +#### host + +主机域名列表。 + +如果设置,客户端将随机选择,服务器将验证。 + +#### path + +!!! warning + + V2Ray 文档称服务端和客户端的路径必须一致,但实际代码允许客户端向路径添加任何后缀。 + sing-box 使用与 V2Ray 相同的行为,但请注意,该行为在 `WebSocket` 和 `HTTPUpgrade` 传输层中不存在。 + +HTTP 请求路径 + +服务器将验证。 + +#### method + +HTTP 请求方法 + +如果设置,服务器将验证。 + +#### headers + +HTTP 请求的额外标头 + +如果设置,服务器将写入响应。 + +#### idle_timeout + +在 HTTP2 服务器中: + +指定闲置客户端应在多长时间内使用 GOAWAY 帧关闭。PING 帧不被视为活动。 + +在 HTTP2 客户端中: + +如果连接上没有收到任何帧,指定一段时间后将使用 PING 帧执行健康检查。需要注意的是,PING 响应被视为已接收的帧,因此如果连接上没有其他流量,则健康检查将在每个间隔执行一次。如果值为零,则不会执行健康检查。 + +默认使用零。 + +#### ping_timeout + +在 HTTP2 客户端中: + +指定发送 PING 帧后,在指定的超时时间内必须接收到响应。如果在指定的超时时间内没有收到 PING 帧的响应,则连接将关闭。默认超时持续时间为 15 秒。 + +### WebSocket + +```json +{ + "type": "ws", + "path": "", + "headers": {}, + "max_early_data": 0, + "early_data_header_name": "" +} +``` + +#### path + +HTTP 请求路径 + +服务器将验证。 + +#### headers + +HTTP 请求的额外标头 + +如果设置,服务器将写入响应。 + +#### max_early_data + +请求中允许的最大有效负载大小。默认启用。 + +#### early_data_header_name + +默认情况下,早期数据在路径而不是标头中发送。 + +要与 Xray-core 兼容,请将其设置为 `Sec-WebSocket-Protocol`。 + +它需要与服务器保持一致。 + +### QUIC + +```json +{ + "type": "quic" +} +``` + +!!! warning "与 v2ray-core 的区别" + + 没有额外的加密支持: + 它基本上是重复加密。 并且 Xray-core 在这里与 v2ray-core 不兼容。 + +### gRPC + +!!! note "" + + 默认安装不包含标准 gRPC (兼容性好,但性能较差), 参阅 [安装](/zh/installation/build-from-source/#构建标记)。 + +```json +{ + "type": "grpc", + "service_name": "TunService", + "idle_timeout": "15s", + "ping_timeout": "15s", + "permit_without_stream": false +} +``` + +#### service_name + +gRPC 服务名称。 + +#### idle_timeout + +在标准 gRPC 服务器/客户端: + +如果传输在此时间段后没有看到任何活动,它会向客户端发送 ping 请求以检查连接是否仍然活动。 + +在默认 gRPC 服务器/客户端: + +它的行为与 HTTP 传输层中的相应设置相同。 + +#### ping_timeout + +在标准 gRPC 服务器/客户端: + +经过一段时间之后,客户端将执行 keepalive 检查并等待活动。如果没有检测到任何活动,则会关闭连接。 + +在默认 gRPC 服务器/客户端: + +它的行为与 HTTP 传输层中的相应设置相同。 + +#### permit_without_stream + +在标准 gRPC 客户端: + +如果启用,客户端传输即使没有活动连接也会发送 keepalive ping。如果禁用,则在没有活动连接时,将忽略 `idle_timeout` 和 `ping_timeout`,并且不会发送 keepalive ping。 + +默认禁用。 + +### HTTPUpgrade + +```json +{ + "type": "httpupgrade", + "host": "", + "path": "", + "headers": {} +} +``` + +#### host + +主机域名。 + +服务器将验证。 + +#### path + +HTTP 请求路径 + +服务器将验证。 + +#### headers + +HTTP 请求的额外标头。 + +如果设置,服务器将写入响应。 diff --git a/docs/configuration/shared/wifi-state.md b/docs/configuration/shared/wifi-state.md new file mode 100644 index 00000000..a32675b3 --- /dev/null +++ b/docs/configuration/shared/wifi-state.md @@ -0,0 +1,41 @@ +--- +icon: material/new-box +--- + +# Wi-Fi State + +!!! quote "Changes in sing-box 1.13.0" + + :material-plus: Linux support + :material-plus: Windows support + +sing-box can monitor Wi-Fi state to enable routing rules based on `wifi_ssid` and `wifi_bssid`. + +### Platform Support + +| Platform | Support | Notes | +|-----------------|------------------|--------------------------| +| Android | :material-check: | In graphical client | +| Apple platforms | :material-check: | In graphical clients | +| Linux | :material-check: | Requires supported daemon | +| Windows | :material-check: | WLAN API | +| Others | :material-close: | | + +### Linux + +!!! question "Since sing-box 1.13.0" + +The following backends are supported and will be auto-detected in order of priority: + +| Backend | Interface | +|------------------|-------------| +| NetworkManager | D-Bus | +| IWD | D-Bus | +| wpa_supplicant | Unix socket | +| ConnMan | D-Bus | + +### Windows + +!!! question "Since sing-box 1.13.0" + +Uses Windows WLAN API. diff --git a/docs/configuration/shared/wifi-state.zh.md b/docs/configuration/shared/wifi-state.zh.md new file mode 100644 index 00000000..c7d4db5f --- /dev/null +++ b/docs/configuration/shared/wifi-state.zh.md @@ -0,0 +1,41 @@ +--- +icon: material/new-box +--- + +# Wi-Fi 状态 + +!!! quote "sing-box 1.13.0 中的更改" + + :material-plus: Linux 支持 + :material-plus: Windows 支持 + +sing-box 可以监控 Wi-Fi 状态,以启用基于 `wifi_ssid` 和 `wifi_bssid` 的路由规则。 + +### 平台支持 + +| 平台 | 支持 | 备注 | +|-----------------|------------------|----------------| +| Android | :material-check: | 仅图形客户端 | +| Apple 平台 | :material-check: | 仅图形客户端 | +| Linux | :material-check: | 需要支持的守护进程 | +| Windows | :material-check: | WLAN API | +| 其他 | :material-close: | | + +### Linux + +!!! question "自 sing-box 1.13.0 起" + +支持以下后端,将按优先级顺序自动探测: + +| 后端 | 接口 | +|------------------|-------------| +| NetworkManager | D-Bus | +| IWD | D-Bus | +| wpa_supplicant | Unix socket | +| ConnMan | D-Bus | + +### Windows + +!!! question "自 sing-box 1.13.0 起" + +使用 Windows WLAN API。 diff --git a/docs/deprecated.md b/docs/deprecated.md new file mode 100644 index 00000000..1eeab10d --- /dev/null +++ b/docs/deprecated.md @@ -0,0 +1,177 @@ +--- +icon: material/delete-alert +--- + +# Deprecated Feature List + +## 1.14.0 + +#### Inline ACME options in TLS + +Inline ACME options (`tls.acme`) are deprecated +and can be replaced by the ACME certificate provider, +check [Migration](../migration/#migrate-inline-acme-to-certificate-provider). + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy `strategy` DNS rule action option + +Legacy `strategy` DNS rule action option is deprecated. + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy `rule_set_ip_cidr_accept_empty` DNS rule item + +Legacy `rule_set_ip_cidr_accept_empty` DNS rule item is deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). + +Old fields will be removed in sing-box 1.16.0. + +#### `independent_cache` DNS option + +`independent_cache` DNS option is deprecated. +The DNS cache now always keys by transport, making this option unnecessary, +check [Migration](../migration/#migrate-independent-dns-cache). + +Old fields will be removed in sing-box 1.16.0. + +#### `store_rdrc` cache file option + +`store_rdrc` cache file option is deprecated, +check [Migration](../migration/#migrate-store-rdrc). + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy Address Filter Fields in DNS rules + +Legacy Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) +in DNS rules are deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). + +Old behavior will be removed in sing-box 1.16.0. + +## 1.12.0 + +#### Legacy DNS server formats + +DNS servers are refactored, +check [Migration](../migration/#migrate-to-new-dns-server-formats). + +Old formats were removed in sing-box 1.14.0. + +#### `outbound` DNS rule item + +Legacy `outbound` DNS rules are deprecated +and can be replaced by dial fields, +check [Migration](../migration/#migrate-outbound-dns-rule-items-to-domain-resolver). + +#### Legacy ECH fields + +ECH support has been migrated to use stdlib in sing-box 1.12.0, +which does not come with support for PQ signature schemes, +so `pq_signature_schemes_enabled` has been deprecated and no longer works. + +Also, `dynamic_record_sizing_disabled` has nothing to do with ECH, +was added by mistake, has been deprecated and no longer works. + +These fields were removed in sing-box 1.13.0. + +## 1.11.0 + +#### Legacy special outbounds + +Legacy special outbounds (`block` / `dns`) are deprecated +and can be replaced by rule actions, +check [Migration](../migration/#migrate-legacy-special-outbounds-to-rule-actions). + +Old fields were removed in sing-box 1.13.0. + +#### Legacy inbound fields + +Legacy inbound fields (`inbound.` are deprecated +and can be replaced by rule actions, +check [Migration](../migration/#migrate-legacy-inbound-fields-to-rule-actions). + +Old fields were removed in sing-box 1.13.0. + +#### Destination override fields in direct outbound + +Destination override fields (`override_address` / `override_port`) in direct outbound are deprecated +and can be replaced by rule actions, +check [Migration](../migration/#migrate-destination-override-fields-to-route-options). + +Old fields were removed in sing-box 1.13.0. + +#### WireGuard outbound + +WireGuard outbound is deprecated and can be replaced by endpoint, +check [Migration](../migration/#migrate-wireguard-outbound-to-endpoint). + +Old outbound was removed in sing-box 1.13.0. + +#### GSO option in TUN + +GSO has no advantages for transparent proxy scenarios, is deprecated and no longer works in TUN. + +Old fields were removed in sing-box 1.13.0. + +## 1.10.0 + +#### TUN address fields are merged + +`inet4_address` and `inet6_address` are merged into `address`, +`inet4_route_address` and `inet6_route_address` are merged into `route_address`, +`inet4_route_exclude_address` and `inet6_route_exclude_address` are merged into `route_exclude_address`. + +Old fields were removed in sing-box 1.12.0. + +#### Match source rule items are renamed + +`rule_set_ipcidr_match_source` route and DNS rule items are renamed to +`rule_set_ip_cidr_match_source` and were removed in sing-box 1.11.0. + +#### Drop support for go1.18 and go1.19 + +Due to maintenance difficulties, sing-box 1.10.0 requires at least Go 1.20 to compile. + +## 1.8.0 + +#### Cache file and related features in Clash API + +`cache_file` and related features in Clash API is migrated to independent `cache_file` options, +check [Migration](/migration/#migrate-cache-file-from-clash-api-to-independent-options). + +#### GeoIP + +GeoIP is deprecated and was removed in sing-box 1.12.0. + +The maxmind GeoIP National Database, as an IP classification database, +is not entirely suitable for traffic bypassing, +and all existing implementations suffer from high memory usage and difficult management. + +sing-box 1.8.0 introduces [rule-set](/configuration/rule-set/), which can completely replace GeoIP, +check [Migration](/migration/#migrate-geoip-to-rule-sets). + +#### Geosite + +Geosite is deprecated and was removed in sing-box 1.12.0. + +Geosite, the `domain-list-community` project maintained by V2Ray as an early traffic bypassing solution, +suffers from a number of problems, including lack of maintenance, inaccurate rules, and difficult management. + +sing-box 1.8.0 introduces [rule-set](/configuration/rule-set/), which can completely replace Geosite, +check [Migration](/migration/#migrate-geosite-to-rule-sets). + +## 1.6.0 + +The following features will be marked deprecated in 1.5.0 and removed entirely in 1.6.0. + +#### ShadowsocksR + +ShadowsocksR support has never been enabled by default, since the most commonly used proxy sales panel in the +illegal industry stopped using this protocol, it does not make sense to continue to maintain it. + +#### Proxy Protocol + +Proxy Protocol is added by Pull Request, has problems, is only used by the backend of HTTP multiplexers such as nginx, +is intrusive, and is meaningless for proxy purposes. diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md new file mode 100644 index 00000000..5dabd69f --- /dev/null +++ b/docs/deprecated.zh.md @@ -0,0 +1,168 @@ +--- +icon: material/delete-alert +--- + +# 废弃功能列表 + +## 1.14.0 + +#### TLS 中的内联 ACME 选项 + +TLS 中的内联 ACME 选项(`tls.acme`)已废弃, +且可以通过 ACME 证书提供者替代, +参阅 [迁移指南](/zh/migration/#迁移内联-acme-到证书提供者)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧版 DNS 规则动作 `strategy` 选项 + +旧版 DNS 规则动作 `strategy` 选项已废弃。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项 + +旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项已废弃, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### `independent_cache` DNS 选项 + +`independent_cache` DNS 选项已废弃。 +DNS 缓存现在始终按传输分离,使此选项不再需要, +参阅[迁移指南](/zh/migration/#迁移-independent-dns-cache)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### `store_rdrc` 缓存文件选项 + +`store_rdrc` 缓存文件选项已废弃, +参阅[迁移指南](/zh/migration/#迁移-store_rdrc)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧版地址筛选字段 (DNS 规则) + +旧版地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +旧行为将在 sing-box 1.16.0 中被移除。 + +## 1.12.0 + +#### 旧的 DNS 服务器格式 + +DNS 服务器已重构, +参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式). + +旧格式已在 sing-box 1.14.0 中被移除。 + +#### `outbound` DNS 规则项 + +旧的 `outbound` DNS 规则已废弃, +且可被拨号字段代替, +参阅 [迁移指南](/zh/migration/#迁移-outbound-dns-规则项到域解析选项). + +#### 旧的 ECH 字段 + +ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支持后量子对等证书签名方案, +因此 `pq_signature_schemes_enabled` 已被弃用且不再工作。 + +另外,`dynamic_record_sizing_disabled` 与 ECH 无关,是错误添加的,现已弃用且不再工作。 + +相关字段已在 sing-box 1.13.0 中被移除。 + +## 1.11.0 + +#### 旧的特殊出站 + +旧的特殊出站(`block` / `dns`)已废弃且可以通过规则动作替代, +参阅 [迁移指南](/zh/migration/#迁移旧的特殊出站到规则动作)。 + +旧字段已在 sing-box 1.13.0 中被移除。 + +#### 旧的入站字段 + +旧的入站字段(`inbound.`)已废弃且可以通过规则动作替代, +参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作)。 + +旧字段已在 sing-box 1.13.0 中被移除。 + +#### direct 出站中的目标地址覆盖字段 + +direct 出站中的目标地址覆盖字段(`override_address` / `override_port`)已废弃且可以通过规则动作替代, +参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 + +旧字段已在 sing-box 1.13.0 中被移除。 + +#### WireGuard 出站 + +WireGuard 出站已废弃且可以通过端点替代, +参阅 [迁移指南](/zh/migration/#迁移-wireguard-出站到端点)。 + +旧出站已在 sing-box 1.13.0 中被移除。 + +#### TUN 的 GSO 字段 + +GSO 对透明代理场景没有优势,已废弃且在 TUN 中不再起作用。 + +旧字段已在 sing-box 1.13.0 中被移除。 + +## 1.10.0 + +#### Match source 规则项已重命名 + +`rule_set_ipcidr_match_source` 路由和 DNS 规则项已被重命名为 +`rule_set_ip_cidr_match_source` 且已在 sing-box 1.11.0 中被移除。 + +#### TUN 地址字段已合并 + +`inet4_address` 和 `inet6_address` 已合并为 `address`, +`inet4_route_address` 和 `inet6_route_address` 已合并为 `route_address`, +`inet4_route_exclude_address` 和 `inet6_route_exclude_address` 已合并为 `route_exclude_address`。 + +旧字段已在 sing-box 1.12.0 中被移除。 + +#### 移除对 go1.18 和 go1.19 的支持 + +由于维护困难,sing-box 1.10.0 要求至少 Go 1.20 才能编译。 + +## 1.8.0 + +#### Clash API 中的 Cache file 及相关功能 + +Clash API 中的 `cache_file` 及相关功能已废弃且已迁移到独立的 `cache_file` 设置, +参阅 [迁移指南](/zh/migration/#将缓存文件从-clash-api-迁移到独立选项)。 + +#### GeoIP + +GeoIP 已废弃且已在 sing-box 1.12.0 中被移除。 + +maxmind GeoIP 国家数据库作为 IP 分类数据库,不完全适合流量绕过, +且现有的实现均存在内存使用大与管理困难的问题。 + +sing-box 1.8.0 引入了[规则集](/zh/configuration/rule-set/), +可以完全替代 GeoIP, 参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 + +#### Geosite + +Geosite 已废弃且已在 sing-box 1.12.0 中被移除。 + +Geosite,即由 V2Ray 维护的 domain-list-community 项目,作为早期流量绕过解决方案, +存在着包括缺少维护、规则不准确和管理困难内的大量问题。 + +sing-box 1.8.0 引入了[规则集](/zh/configuration/rule-set/), +可以完全替代 Geosite,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 + +## 1.6.0 + +下列功能已在 1.5.0 中标记为已弃用,并在 1.6.0 中完全删除。 + +#### ShadowsocksR + +ShadowsocksR 支持从未默认启用,自从常用的黑产代理销售面板停止使用该协议,继续维护它是没有意义的。 + +#### Proxy Protocol + +Proxy Protocol 支持由 Pull Request 添加,存在问题且仅由 HTTP 多路复用器(如 nginx)的后端使用,具有侵入性,对于代理目的毫无意义。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..2af7222e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +--- +description: Welcome to the wiki page for the sing-box project. +--- + +# :material-home: Home + +Welcome to the wiki page for the sing-box project. + +The universal proxy platform. + +## License + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. +``` diff --git a/docs/index.zh.md b/docs/index.zh.md new file mode 100644 index 00000000..72877118 --- /dev/null +++ b/docs/index.zh.md @@ -0,0 +1,31 @@ +--- +description: 欢迎来到该 sing-box 项目的文档页。 +--- + +# :material-home: 开始 + +欢迎来到该 sing-box 项目的文档页。 + +通用代理平台。 + +## 授权 + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. +``` diff --git a/docs/installation/build-from-source.md b/docs/installation/build-from-source.md new file mode 100644 index 00000000..48b53b17 --- /dev/null +++ b/docs/installation/build-from-source.md @@ -0,0 +1,126 @@ +--- +icon: material/file-code +--- + +# Build from source + +## :material-graph: Requirements + +### sing-box 1.11 + +* Go 1.23.1 - ~ + +### sing-box 1.10 + +* Go 1.20.0 - ~ + +### sing-box 1.9 + +* Go 1.18.5 - 1.22.x +* Go 1.20.0 - 1.22.x with tag `with_quic`, or `with_utls` enabled + +## :material-fast-forward: Simple Build + +```bash +make +``` + +Or build and install binary to `$GOBIN`: + +```bash +make install +``` + +## :material-cog: Custom Build + +```bash +TAGS="tag_a tag_b" make +``` + +or + +```bash +go build -tags "tag_a tag_b" ./cmd/sing-box +``` + +## :material-folder-settings: Build Tags + +| Build Tag | Enabled by default | Description | +|------------------------------------|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `with_quic` | :material-check: | Build with QUIC support, see [QUIC and HTTP3 DNS transports](/configuration/dns/server/), [Naive inbound](/configuration/inbound/naive/), [Hysteria Inbound](/configuration/inbound/hysteria/), [Hysteria Outbound](/configuration/outbound/hysteria/) and [V2Ray Transport#QUIC](/configuration/shared/v2ray-transport#quic). | +| `with_grpc` | :material-close:️ | Build with standard gRPC support, see [V2Ray Transport#gRPC](/configuration/shared/v2ray-transport#grpc). | +| `with_dhcp` | :material-check: | Build with DHCP support, see [DHCP DNS transport](/configuration/dns/server/). | +| `with_wireguard` | :material-check: | Build with WireGuard support, see [WireGuard outbound](/configuration/outbound/wireguard/). | +| `with_utls` | :material-check: | Build with [uTLS](https://github.com/refraction-networking/utls) support for TLS outbound, see [TLS](/configuration/shared/tls#utls). | +| `with_acme` | :material-check: | Build with ACME TLS certificate issuer support, see [TLS](/configuration/shared/tls/). | +| `with_clash_api` | :material-check: | Build with Clash API support, see [Experimental](/configuration/experimental#clash-api-fields). | +| `with_v2ray_api` | :material-close:️ | Build with V2Ray API support, see [Experimental](/configuration/experimental#v2ray-api-fields). | +| `with_gvisor` | :material-check: | Build with gVisor support, see [Tun inbound](/configuration/inbound/tun#stack) and [WireGuard outbound](/configuration/outbound/wireguard#system_interface). | +| `with_embedded_tor` (CGO required) | :material-close:️ | Build with embedded Tor support, see [Tor outbound](/configuration/outbound/tor/). | +| `with_tailscale` | :material-check: | Build with Tailscale support, see [Tailscale endpoint](/configuration/endpoint/tailscale). | +| `with_ccm` | :material-check: | Build with Claude Code Multiplexer service support. | +| `with_ocm` | :material-check: | Build with OpenAI Codex Multiplexer service support. | +| `with_naive_outbound` | :material-check: | Build with NaiveProxy outbound support, see [NaiveProxy outbound](/configuration/outbound/naive/). | +| `with_cloudflared` | :material-check: | Build with Cloudflare Tunnel inbound support, see [Cloudflared inbound](/configuration/inbound/cloudflared/). | +| `badlinkname` | :material-check: | Enable `go:linkname` access to internal standard library functions. Required because the Go standard library does not expose many low-level APIs needed by this project, and reimplementing them externally is impractical. Used for kTLS (kernel TLS offload) and raw TLS record manipulation. | +| `tfogo_checklinkname0` | :material-check: | Companion to `badlinkname`. Go 1.23+ enforces `go:linkname` restrictions via the linker; this tag signals the build uses `-checklinkname=0` to bypass that enforcement. | + +It is not recommended to change the default build tag list unless you really know what you are adding. + +## :material-wrench: Linker Flags + +The following `-ldflags` are used in official builds: + +| Flag | Description | +|-------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `-X 'internal/godebug.defaultGODEBUG=multipathtcp=0'` | Go 1.24 enabled Multipath TCP for listeners by default (`multipathtcp=2`). This may cause errors on low-level sockets, and sing-box has its own MPTCP control (`tcp_multi_path` option). This flag disables the Go default. | +| `-checklinkname=0` | Go 1.23+ linker rejects unauthorized `go:linkname` usage. This flag disables the check, required together with the `badlinkname` build tag. | + +## :material-package-variant: For Downstream Packagers + +The default build tag lists and linker flags are available as files in the repository for downstream packagers to reference directly: + +| File | Description | +|------|-------------| +| `release/DEFAULT_BUILD_TAGS` | Default for Linux (common architectures), Darwin, and Android. | +| `release/DEFAULT_BUILD_TAGS_WINDOWS` | Default for Windows (includes `with_purego`). | +| `release/DEFAULT_BUILD_TAGS_OTHERS` | Default for other platforms (no `with_naive_outbound`). | +| `release/LDFLAGS` | Required linker flags (see above). | + +## :material-layers: with_naive_outbound + +NaiveProxy outbound requires special build configurations depending on your target platform. + +### Supported Platforms + +| Platform | Architectures | Mode | Requirements | +|-----------------|--------------------------------------------------------|--------|-----------------------------------------------------------------| +| Linux | amd64, arm64 | purego | None (library included in official releases) | +| Linux | 386, amd64, arm, arm64, mipsle, mips64le, riscv64, loong64 | CGO | Chromium toolchain, glibc >= 2.31 (loong64: >= 2.36) at runtime | +| Linux (musl) | 386, amd64, arm, arm64, mipsle, riscv64, loong64 | CGO | Chromium toolchain | +| Windows | amd64, arm64 | purego | None (library included in official releases) | +| Apple platforms | * | CGO | Xcode | +| Android | * | CGO | Android NDK | + +### Windows + +Use `with_purego` tag. + +For official releases, `libcronet.dll` is included in the archive. For self-built binaries, download from [cronet-go releases](https://github.com/sagernet/cronet-go/releases) and place in the same directory as `sing-box.exe` or in a directory listed in `PATH`. + +### Linux (purego, amd64/arm64 only) + +Use `with_purego` tag. + +For official releases, `libcronet.so` is included in the archive. For self-built binaries, download from [cronet-go releases](https://github.com/sagernet/cronet-go/releases) and place in the same directory as sing-box binary or in system library path. + +### Linux (CGO) + +See [cronet-go](https://github.com/sagernet/cronet-go#linux-build-instructions). + +- **glibc build**: Requires glibc >= 2.31 at runtime +- **musl build**: Use `with_musl` tag, statically linked, no runtime requirements + +### Apple platforms / Android + +See [cronet-go](https://github.com/sagernet/cronet-go). diff --git a/docs/installation/build-from-source.zh.md b/docs/installation/build-from-source.zh.md new file mode 100644 index 00000000..4ffebdc6 --- /dev/null +++ b/docs/installation/build-from-source.zh.md @@ -0,0 +1,130 @@ +--- +icon: material/file-code +--- + +# 从源代码构建 + +## :material-graph: 要求 + +### sing-box 1.11 + +* Go 1.23.1 - ~ + +### sing-box 1.10 + +* Go 1.20.0 - ~ +* Go 1.21.0 - ~ with tag `with_ech` enabled + +### sing-box 1.9 + +* Go 1.18.5 - 1.22.x +* Go 1.20.0 - 1.22.x with tag `with_quic`, or `with_utls` enabled +* Go 1.21.0 - 1.22.x with tag `with_ech` enabled + +您可以从 https://go.dev/doc/install 下载并安装 Go,推荐使用最新版本。 + +## :material-fast-forward: 快速开始 + +```bash +make +``` + +或者构建二进制文件并将其安装到 `$GOBIN`: + +```bash +make install +``` + +## :material-cog: 自定义构建 + +```bash +TAGS="tag_a tag_b" make +``` + +or + +```bash +go build -tags "tag_a tag_b" ./cmd/sing-box +``` + +## :material-folder-settings: 构建标记 + +| 构建标记 | 默认启动 | 说明 | +|------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `with_quic` | :material-check: | Build with QUIC support, see [QUIC and HTTP3 DNS transports](/zh/configuration/dns/server/), [Naive inbound](/zh/configuration/inbound/naive/), [Hysteria Inbound](/zh/configuration/inbound/hysteria/), [Hysteria Outbound](/zh/configuration/outbound/hysteria/) and [V2Ray Transport#QUIC](/zh/configuration/shared/v2ray-transport#quic). | +| `with_grpc` | :material-close:️ | Build with standard gRPC support, see [V2Ray Transport#gRPC](/zh/configuration/shared/v2ray-transport#grpc). | +| `with_dhcp` | :material-check: | Build with DHCP support, see [DHCP DNS transport](/zh/configuration/dns/server/). | +| `with_wireguard` | :material-check: | Build with WireGuard support, see [WireGuard outbound](/zh/configuration/outbound/wireguard/). | +| `with_utls` | :material-check: | Build with [uTLS](https://github.com/refraction-networking/utls) support for TLS outbound, see [TLS](/zh/configuration/shared/tls#utls). | +| `with_acme` | :material-check: | Build with ACME TLS certificate issuer support, see [TLS](/zh/configuration/shared/tls/). | +| `with_clash_api` | :material-check: | Build with Clash API support, see [Experimental](/zh/configuration/experimental#clash-api-fields). | +| `with_v2ray_api` | :material-close:️ | Build with V2Ray API support, see [Experimental](/zh/configuration/experimental#v2ray-api-fields). | +| `with_gvisor` | :material-check: | Build with gVisor support, see [Tun inbound](/zh/configuration/inbound/tun#stack) and [WireGuard outbound](/zh/configuration/outbound/wireguard#system_interface). | +| `with_embedded_tor` (CGO required) | :material-close:️ | Build with embedded Tor support, see [Tor outbound](/zh/configuration/outbound/tor/). | +| `with_tailscale` | :material-check: | 构建 Tailscale 支持,参阅 [Tailscale 端点](/zh/configuration/endpoint/tailscale)。 | +| `with_ccm` | :material-check: | 构建 Claude Code Multiplexer 服务支持。 | +| `with_ocm` | :material-check: | 构建 OpenAI Codex Multiplexer 服务支持。 | +| `with_naive_outbound` | :material-check: | 构建 NaiveProxy 出站支持,参阅 [NaiveProxy 出站](/zh/configuration/outbound/naive/)。 | +| `with_cloudflared` | :material-check: | 构建 Cloudflare Tunnel 入站支持,参阅 [Cloudflared 入站](/zh/configuration/inbound/cloudflared/)。 | +| `badlinkname` | :material-check: | 启用 `go:linkname` 以访问标准库内部函数。Go 标准库未提供本项目需要的许多底层 API,且在外部重新实现不切实际。用于 kTLS(内核 TLS 卸载)和原始 TLS 记录操作。 | +| `tfogo_checklinkname0` | :material-check: | `badlinkname` 的伴随标记。Go 1.23+ 链接器强制限制 `go:linkname` 使用;此标记表示构建使用 `-checklinkname=0` 以绕过该限制。 | + +除非您确实知道您正在启用什么,否则不建议更改默认构建标签列表。 + +## :material-wrench: 链接器标志 + +以下 `-ldflags` 在官方构建中使用: + +| 标志 | 说明 | +|-------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `-X 'internal/godebug.defaultGODEBUG=multipathtcp=0'` | Go 1.24 默认为监听器启用 Multipath TCP(`multipathtcp=2`)。这可能在底层 socket 上导致错误,且 sing-box 有自己的 MPTCP 控制(`tcp_multi_path` 选项)。此标志禁用 Go 的默认行为。 | +| `-checklinkname=0` | Go 1.23+ 链接器拒绝未授权的 `go:linkname` 使用。此标志禁用该检查,需要与 `badlinkname` 构建标记一起使用。 | + +## :material-package-variant: 下游打包者 + +默认构建标签列表和链接器标志以文件形式存放在仓库中,供下游打包者直接引用: + +| 文件 | 说明 | +|------|------| +| `release/DEFAULT_BUILD_TAGS` | Linux(常见架构)、Darwin 和 Android 的默认标签。 | +| `release/DEFAULT_BUILD_TAGS_WINDOWS` | Windows 的默认标签(包含 `with_purego`)。 | +| `release/DEFAULT_BUILD_TAGS_OTHERS` | 其他平台的默认标签(不含 `with_naive_outbound`)。 | +| `release/LDFLAGS` | 必需的链接器标志(参见上文)。 | + +## :material-layers: with_naive_outbound + +NaiveProxy 出站需要根据目标平台进行特殊的构建配置。 + +### 支持的平台 + +| 平台 | 架构 | 模式 | 要求 | +|--------------|----------------------------------------------------------|--------|-----------------------------------------------------| +| Linux | amd64, arm64 | purego | 无(官方发布版本已包含库文件) | +| Linux | 386, amd64, arm, arm64, mipsle, mips64le, riscv64, loong64 | CGO | Chromium 工具链,运行时需要 glibc >= 2.31(loong64: >= 2.36) | +| Linux (musl) | 386, amd64, arm, arm64, mipsle, riscv64, loong64 | CGO | Chromium 工具链 | +| Windows | amd64, arm64 | purego | 无(官方发布版本已包含库文件) | +| Apple 平台 | * | CGO | Xcode | +| Android | * | CGO | Android NDK | + +### Windows + +使用 `with_purego` 标记。 + +官方发布版本已包含 `libcronet.dll`。自行构建时,从 [cronet-go releases](https://github.com/sagernet/cronet-go/releases) 下载并放置在 `sing-box.exe` 相同目录或 `PATH` 中的任意目录。 + +### Linux (purego, 仅 amd64/arm64) + +使用 `with_purego` 标记。 + +官方发布版本已包含 `libcronet.so`。自行构建时,从 [cronet-go releases](https://github.com/sagernet/cronet-go/releases) 下载并放置在 sing-box 二进制文件相同目录或系统库路径中。 + +### Linux (CGO) + +参阅 [cronet-go](https://github.com/sagernet/cronet-go#linux-build-instructions)。 + +- **glibc 构建**:运行时需要 glibc >= 2.31 +- **musl 构建**:使用 `with_musl` 标记,静态链接,无运行时要求 + +### Apple 平台 / Android + +参阅 [cronet-go](https://github.com/sagernet/cronet-go)。 diff --git a/docs/installation/docker.md b/docs/installation/docker.md new file mode 100644 index 00000000..ae1682dd --- /dev/null +++ b/docs/installation/docker.md @@ -0,0 +1,31 @@ +--- +icon: material/docker +--- + +# Docker + +## :material-console: Command + +```bash +docker run -d \ + -v /etc/sing-box:/etc/sing-box/ \ + --name=sing-box \ + --restart=always \ + ghcr.io/sagernet/sing-box \ + -D /var/lib/sing-box \ + -C /etc/sing-box/ run +``` + +## :material-box-shadow: Compose + +```yaml +version: "3.8" +services: + sing-box: + image: ghcr.io/sagernet/sing-box + container_name: sing-box + restart: always + volumes: + - /etc/sing-box:/etc/sing-box/ + command: -D /var/lib/sing-box -C /etc/sing-box/ run +``` diff --git a/docs/installation/docker.zh.md b/docs/installation/docker.zh.md new file mode 100644 index 00000000..ecc66e3a --- /dev/null +++ b/docs/installation/docker.zh.md @@ -0,0 +1,31 @@ +--- +icon: material/docker +--- + +# Docker + +## :material-console: 命令 + +```bash +docker run -d \ + -v /etc/sing-box:/etc/sing-box/ \ + --name=sing-box \ + --restart=always \ + ghcr.io/sagernet/sing-box \ + -D /var/lib/sing-box \ + -C /etc/sing-box/ run +``` + +## :material-box-shadow: Compose + +```yaml +version: "3.8" +services: + sing-box: + image: ghcr.io/sagernet/sing-box + container_name: sing-box + restart: always + volumes: + - /etc/sing-box:/etc/sing-box/ + command: -D /var/lib/sing-box -C /etc/sing-box/ run +``` diff --git a/docs/installation/package-manager.md b/docs/installation/package-manager.md new file mode 100644 index 00000000..fe59c73a --- /dev/null +++ b/docs/installation/package-manager.md @@ -0,0 +1,157 @@ +--- +icon: material/package +--- + +# Package Manager + +## :material-tram: Repository Installation + +=== ":material-debian: Debian / APT" + + ```bash + sudo mkdir -p /etc/apt/keyrings && + sudo curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc && + sudo chmod a+r /etc/apt/keyrings/sagernet.asc && + echo ' + Types: deb + URIs: https://deb.sagernet.org/ + Suites: * + Components: * + Enabled: yes + Signed-By: /etc/apt/keyrings/sagernet.asc + ' | sudo tee /etc/apt/sources.list.d/sagernet.sources && + sudo apt-get update && + sudo apt-get install sing-box # or sing-box-beta + ``` + +=== ":material-redhat: Redhat / DNF 5" + + ```bash + sudo dnf config-manager addrepo --from-repofile=https://sing-box.app/sing-box.repo && + sudo dnf install sing-box # or sing-box-beta + ``` + +=== ":material-redhat: Redhat / DNF 4" + + ```bash + sudo dnf config-manager --add-repo https://sing-box.app/sing-box.repo && + sudo dnf -y install dnf-plugins-core && + sudo dnf install sing-box # or sing-box-beta + ``` + +## :material-download-box: Manual Installation + +The script download and install the latest package from GitHub releases +for deb or rpm based Linux distributions, ArchLinux and OpenWrt. + +```shell +curl -fsSL https://sing-box.app/install.sh | sh +``` + +or latest beta: + +```shell +curl -fsSL https://sing-box.app/install.sh | sh -s -- --beta +``` + +or specific version: + +```shell +curl -fsSL https://sing-box.app/install.sh | sh -s -- --version +``` + +## :material-book-lock-open: Managed Installation + +=== ":material-linux: Linux" + + | Type | Platform | Command | Link | + |----------|---------------|------------------------------|---------------------------------------------------------------------------------------------------------------| + | AUR | Arch Linux | `? -S sing-box` | [![AUR package](https://repology.org/badge/version-for-repo/aur/sing-box.svg)][aur] | + | nixpkgs | NixOS | `nix-env -iA nixos.sing-box` | [![nixpkgs unstable package](https://repology.org/badge/version-for-repo/nix_unstable/sing-box.svg)][nixpkgs] | + | Homebrew | macOS / Linux | `brew install sing-box` | [![Homebrew package](https://repology.org/badge/version-for-repo/homebrew/sing-box.svg)][brew] | + | APK | Alpine | `apk add sing-box` | [![Alpine Linux Edge package](https://repology.org/badge/version-for-repo/alpine_edge/sing-box.svg)][alpine] | + | DEB | AOSC | `apt install sing-box` | [![AOSC package](https://repology.org/badge/version-for-repo/aosc/sing-box.svg)][aosc] | + +=== ":material-apple: macOS" + + | Type | Platform | Command | Link | + |----------|----------|-------------------------|------------------------------------------------------------------------------------------------| + | Homebrew | macOS | `brew install sing-box` | [![Homebrew package](https://repology.org/badge/version-for-repo/homebrew/sing-box.svg)][brew] | + +=== ":material-microsoft-windows: Windows" + + | Type | Platform | Command | Link | + |------------|----------|---------------------------|-----------------------------------------------------------------------------------------------------| + | Scoop | Windows | `scoop install sing-box` | [![Scoop package](https://repology.org/badge/version-for-repo/scoop/sing-box.svg)][scoop] | + | Chocolatey | Windows | `choco install sing-box` | [![Chocolatey package](https://repology.org/badge/version-for-repo/chocolatey/sing-box.svg)][choco] | + | winget | Windows | `winget install sing-box` | [![winget package](https://repology.org/badge/version-for-repo/winget/sing-box.svg)][winget] | + +=== ":material-android: Android" + + | Type | Platform | Command | Link | + |--------|----------|--------------------|----------------------------------------------------------------------------------------------| + | Termux | Android | `pkg add sing-box` | [![Termux package](https://repology.org/badge/version-for-repo/termux/sing-box.svg)][termux] | + +=== ":material-freebsd: FreeBSD" + + | Type | Platform | Command | Link | + |------------|----------|------------------------|--------------------------------------------------------------------------------------------| + | FreshPorts | FreeBSD | `pkg install sing-box` | [![FreeBSD port](https://repology.org/badge/version-for-repo/freebsd/sing-box.svg)][ports] | + +## :material-alert: Problematic Sources + +| Type | Platform | Link | Promblem(s) | +|------------|----------|-------------------------------------------------------------------------------------------|-----------------------------------------| +| DEB | AOSC | [aosc-os-abbs](https://github.com/AOSC-Dev/aosc-os-abbs/tree/stable/app-network/sing-box) | Problematic build tag list modification | +| Homebrew | / | [homebrew-core][brew] | Problematic build tag list modification | +| Termux | Android | [termux-packages][termux] | Problematic build tag list modification | +| FreshPorts | FreeBSD | [FreeBSD ports][ports] | Old Go (go1.20) | + +If you are a user of them, please report issues to them: + +1. Please do not modify release build tags without full understanding of the related functionality: enabling non-default + labels may result in decreased performance; the lack of default labels may cause user confusion. +2. sing-box supports compiling with some older Go versions, but it is not recommended (especially versions that are no + longer supported by Go). + +## :material-book-multiple: Service Management + +For Linux systems with [systemd][systemd], usually the installation already includes a sing-box service, +you can manage the service using the following command: + +| Operation | Command | +|-----------|-----------------------------------------------| +| Enable | `sudo systemctl enable sing-box` | +| Disable | `sudo systemctl disable sing-box` | +| Start | `sudo systemctl start sing-box` | +| Stop | `sudo systemctl stop sing-box` | +| Kill | `sudo systemctl kill sing-box` | +| Restart | `sudo systemctl restart sing-box` | +| Logs | `sudo journalctl -u sing-box --output cat -e` | +| New Logs | `sudo journalctl -u sing-box --output cat -f` | + +[alpine]: https://pkgs.alpinelinux.org/packages?name=sing-box + +[aur]: https://aur.archlinux.org/packages/sing-box + +[nixpkgs]: https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/tools/networking/sing-box/default.nix + +[brew]: https://formulae.brew.sh/formula/sing-box + +[openwrt]: https://github.com/openwrt/packages/tree/master/net/sing-box + +[immortalwrt]: https://github.com/immortalwrt/packages/tree/master/net/sing-box + +[choco]: https://chocolatey.org/packages/sing-box + +[scoop]: https://github.com/ScoopInstaller/Main/blob/master/bucket/sing-box.json + +[winget]: https://github.com/microsoft/winget-pkgs/tree/master/manifests/s/SagerNet/sing-box + +[termux]: https://github.com/termux/termux-packages/tree/master/packages/sing-box + +[ports]: https://www.freshports.org/net/sing-box + +[aosc]: https://packages.aosc.io/packages/sing-box + +[systemd]: https://systemd.io/ diff --git a/docs/installation/package-manager.zh.md b/docs/installation/package-manager.zh.md new file mode 100644 index 00000000..f942e924 --- /dev/null +++ b/docs/installation/package-manager.zh.md @@ -0,0 +1,150 @@ +--- +icon: material/package +--- + +# 包管理器 + +## :material-tram: 仓库安装 + +=== ":material-debian: Debian / APT" + + ```bash + sudo mkdir -p /etc/apt/keyrings && + sudo curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc && + sudo chmod a+r /etc/apt/keyrings/sagernet.asc && + echo ' + Types: deb + URIs: https://deb.sagernet.org/ + Suites: * + Components: * + Enabled: yes + Signed-By: /etc/apt/keyrings/sagernet.asc + ' | sudo tee /etc/apt/sources.list.d/sagernet.sources && + sudo apt-get update && + sudo apt-get install sing-box # or sing-box-beta + ``` + +=== ":material-redhat: Redhat / DNF 5" + + ```bash + sudo dnf config-manager addrepo --from-repofile=https://sing-box.app/sing-box.repo && + sudo dnf install sing-box # or sing-box-beta + ``` + +=== ":material-redhat: Redhat / DNF 4" + + ```bash + sudo dnf config-manager --add-repo https://sing-box.app/sing-box.repo && + sudo dnf -y install dnf-plugins-core && + sudo dnf install sing-box # or sing-box-beta + ``` + +## :material-download-box: 手动安装 + +该脚本从 GitHub 发布中下载并安装最新的软件包,适用于基于 deb 或 rpm 的 Linux 发行版、ArchLinux 和 OpenWrt。 + +```shell +curl -fsSL https://sing-box.app/install.sh | sh +``` + +或最新测试版: + +```shell +curl -fsSL https://sing-box.app/install.sh | sh -s -- --beta +``` + +或指定版本: + +```shell +curl -fsSL https://sing-box.app/install.sh | sh -s -- --version +``` + +## :material-book-lock-open: 托管安装 + +=== ":material-linux: Linux" + + | 类型 | 平台 | 命令 | 链接 | + |----------|---------------|------------------------------|---------------------------------------------------------------------------------------------------------------| + | AUR | Arch Linux | `? -S sing-box` | [![AUR package](https://repology.org/badge/version-for-repo/aur/sing-box.svg)][aur] | + | nixpkgs | NixOS | `nix-env -iA nixos.sing-box` | [![nixpkgs unstable package](https://repology.org/badge/version-for-repo/nix_unstable/sing-box.svg)][nixpkgs] | + | Homebrew | macOS / Linux | `brew install sing-box` | [![Homebrew package](https://repology.org/badge/version-for-repo/homebrew/sing-box.svg)][brew] | + | APK | Alpine | `apk add sing-box` | [![Alpine Linux Edge package](https://repology.org/badge/version-for-repo/alpine_edge/sing-box.svg)][alpine] | + | DEB | AOSC | `apt install sing-box` | [![AOSC package](https://repology.org/badge/version-for-repo/aosc/sing-box.svg)][aosc] | + +=== ":material-apple: macOS" + + | 类型 | 平台 | 命令 | 链接 | + |----------|-------|-------------------------|------------------------------------------------------------------------------------------------| + | Homebrew | macOS | `brew install sing-box` | [![Homebrew package](https://repology.org/badge/version-for-repo/homebrew/sing-box.svg)][brew] | + +=== ":material-microsoft-windows: Windows" + + | 类型 | 平台 | 命令 | 链接 | + |------------|---------|---------------------------|-----------------------------------------------------------------------------------------------------| + | Scoop | Windows | `scoop install sing-box` | [![Scoop package](https://repology.org/badge/version-for-repo/scoop/sing-box.svg)][scoop] | + | Chocolatey | Windows | `choco install sing-box` | [![Chocolatey package](https://repology.org/badge/version-for-repo/chocolatey/sing-box.svg)][choco] | + | winget | Windows | `winget install sing-box` | [![winget package](https://repology.org/badge/version-for-repo/winget/sing-box.svg)][winget] | + +=== ":material-android: Android" + + | 类型 | 平台 | 命令 | 链接 | + |--------|---------|--------------------|----------------------------------------------------------------------------------------------| + | Termux | Android | `pkg add sing-box` | [![Termux package](https://repology.org/badge/version-for-repo/termux/sing-box.svg)][termux] | + +=== ":material-freebsd: FreeBSD" + + | 类型 | 平台 | 命令 | 链接 | + |------------|---------|------------------------|--------------------------------------------------------------------------------------------| + | FreshPorts | FreeBSD | `pkg install sing-box` | [![FreeBSD port](https://repology.org/badge/version-for-repo/freebsd/sing-box.svg)][ports] | + +## :material-alert: 存在问题的源 + +| 类型 | 平台 | 链接 | 原因 | +|------------|---------|-------------------------------------------------------------------------------------------|-----------------| +| DEB | AOSC | [aosc-os-abbs](https://github.com/AOSC-Dev/aosc-os-abbs/tree/stable/app-network/sing-box) | 存在问题的构建标志列表修改 | +| Homebrew | / | [homebrew-core][brew] | 存在问题的构建标志列表修改 | +| Termux | Android | [termux-packages][termux] | 存在问题的构建标志列表修改 | +| FreshPorts | FreeBSD | [FreeBSD ports][ports] | 太旧的 Go (go1.20) | + +如果您是其用户,请向他们报告问题: + +1. 在未完全了解相关功能的情况下,请勿修改发布版本标签:启用非默认标签可能会导致性能下降;缺少默认标签可能会引起用户混淆。 +2. sing-box 支持使用一些较旧的 Go 版本进行编译,但不推荐使用(特别是已不再受 Go 支持的版本)。 + +## :material-book-multiple: 服务管理 + +对于带有 [systemd][systemd] 的 Linux 系统,通常安装已经包含 sing-box 服务, +您可以使用以下命令管理服务: + +| 行动 | 命令 | +|------|-----------------------------------------------| +| 启用 | `sudo systemctl enable sing-box` | +| 禁用 | `sudo systemctl disable sing-box` | +| 启动 | `sudo systemctl start sing-box` | +| 停止 | `sudo systemctl stop sing-box` | +| 强行停止 | `sudo systemctl kill sing-box` | +| 重新启动 | `sudo systemctl restart sing-box` | +| 查看日志 | `sudo journalctl -u sing-box --output cat -e` | +| 实时日志 | `sudo journalctl -u sing-box --output cat -f` | + +[alpine]: https://pkgs.alpinelinux.org/packages?name=sing-box + +[aur]: https://aur.archlinux.org/packages/sing-box + +[nixpkgs]: https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/tools/networking/sing-box/default.nix + +[brew]: https://formulae.brew.sh/formula/sing-box + +[choco]: https://chocolatey.org/packages/sing-box + +[scoop]: https://github.com/ScoopInstaller/Main/blob/master/bucket/sing-box.json + +[winget]: https://github.com/microsoft/winget-pkgs/tree/master/manifests/s/SagerNet/sing-box + +[termux]: https://github.com/termux/termux-packages/tree/master/packages/sing-box + +[ports]: https://www.freshports.org/net/sing-box + +[aosc]: https://packages.aosc.io/packages/sing-box + +[systemd]: https://systemd.io/ diff --git a/docs/installation/tools/arch-install.sh b/docs/installation/tools/arch-install.sh new file mode 100644 index 00000000..50ce5767 --- /dev/null +++ b/docs/installation/tools/arch-install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e -o pipefail + +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + 'x86_64') ARCH='amd64';; + 'x86' | 'i686' | 'i386') ARCH='386';; + 'aarch64' | 'arm64') ARCH='arm64';; + 'armv7l') ARCH='armv7';; + 's390x') ARCH='s390x';; + *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; +esac + +VERSION=$(curl -s https://api.github.com/repos/SagerNet/sing-box/releases/latest \ + | grep tag_name \ + | cut -d ":" -f2 \ + | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') + +curl -Lo sing-box.pkg.tar.zst "https://github.com/SagerNet/sing-box/releases/download/v${VERSION}/sing-box_${VERSION}_linux_${ARCH}.pkg.tar.zst" +sudo pacman -U sing-box.pkg.tar.zst +rm sing-box.pkg.tar.zst diff --git a/docs/installation/tools/deb-install.sh b/docs/installation/tools/deb-install.sh new file mode 100644 index 00000000..587b6f61 --- /dev/null +++ b/docs/installation/tools/deb-install.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e -o pipefail + +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + 'x86_64') ARCH='amd64';; + 'x86' | 'i686' | 'i386') ARCH='386';; + 'aarch64' | 'arm64') ARCH='arm64';; + 'armv7l') ARCH='armv7';; + 's390x') ARCH='s390x';; + *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; +esac + +VERSION=$(curl -s https://api.github.com/repos/SagerNet/sing-box/releases/latest \ + | grep tag_name \ + | cut -d ":" -f2 \ + | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') + +curl -Lo sing-box.deb "https://github.com/SagerNet/sing-box/releases/download/v${VERSION}/sing-box_${VERSION}_linux_${ARCH}.deb" +sudo dpkg -i sing-box.deb +rm sing-box.deb + diff --git a/docs/installation/tools/install.sh b/docs/installation/tools/install.sh new file mode 100644 index 00000000..7bfbc365 --- /dev/null +++ b/docs/installation/tools/install.sh @@ -0,0 +1,127 @@ +#!/bin/sh + +download_beta=false +download_version="" + +while [ $# -gt 0 ]; do + case "$1" in + --beta) + download_beta=true + shift + ;; + --version) + shift + if [ $# -eq 0 ]; then + echo "Missing argument for --version" + echo "Usage: $0 [--beta] [--version ]" + exit 1 + fi + download_version="$1" + shift + ;; + *) + echo "Unknown argument: $1" + echo "Usage: $0 [--beta] [--version ]" + exit 1 + ;; + esac +done + +if command -v pacman >/dev/null 2>&1; then + os="linux" + arch=$(uname -m) + package_suffix=".pkg.tar.zst" + package_install="pacman -U --noconfirm" +elif command -v dpkg >/dev/null 2>&1; then + os="linux" + arch=$(dpkg --print-architecture) + package_suffix=".deb" + package_install="dpkg -i" +elif command -v dnf >/dev/null 2>&1; then + os="linux" + arch=$(uname -m) + package_suffix=".rpm" + package_install="dnf install -y" +elif command -v rpm >/dev/null 2>&1; then + os="linux" + arch=$(uname -m) + package_suffix=".rpm" + package_install="rpm -i" +elif command -v apk >/dev/null 2>&1 && [ -f /etc/os-release ] && grep -q OPENWRT_ARCH /etc/os-release; then + os="openwrt" + . /etc/os-release + arch="$OPENWRT_ARCH" + package_suffix=".apk" + package_install="apk add --allow-untrusted" +elif command -v apk >/dev/null 2>&1; then + os="linux" + arch=$(apk --print-arch) + package_suffix=".apk" + package_install="apk add --allow-untrusted" +elif command -v opkg >/dev/null 2>&1; then + os="openwrt" + . /etc/os-release + arch="$OPENWRT_ARCH" + package_suffix=".ipk" + package_install="opkg update && opkg install" +else + echo "Missing supported package manager." + exit 1 +fi + +if [ -z "$download_version" ]; then + if [ "$download_beta" != "true" ]; then + if [ -n "$GITHUB_TOKEN" ]; then + latest_release=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/SagerNet/sing-box/releases/latest) + else + latest_release=$(curl -s https://api.github.com/repos/SagerNet/sing-box/releases/latest) + fi + curl_exit_status=$? + if [ $curl_exit_status -ne 0 ]; then + exit $curl_exit_status + fi + if [ "$(echo "$latest_release" | grep tag_name | wc -l)" -eq 0 ]; then + echo "$latest_release" + exit 1 + fi + download_version=$(echo "$latest_release" | grep tag_name | head -n 1 | awk -F: '{print $2}' | sed 's/[", v]//g') + else + if [ -n "$GITHUB_TOKEN" ]; then + latest_release=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/SagerNet/sing-box/releases) + else + latest_release=$(curl -s https://api.github.com/repos/SagerNet/sing-box/releases) + fi + curl_exit_status=$? + if [ $curl_exit_status -ne 0 ]; then + exit $curl_exit_status + fi + if [ "$(echo "$latest_release" | grep tag_name | wc -l)" -eq 0 ]; then + echo "$latest_release" + exit 1 + fi + download_version=$(echo "$latest_release" | grep tag_name | head -n 1 | awk -F: '{print $2}' | sed 's/[", v]//g') + fi +fi + +package_name="sing-box_${download_version}_${os}_${arch}${package_suffix}" +package_url="https://github.com/SagerNet/sing-box/releases/download/v${download_version}/${package_name}" + +echo "Downloading $package_url" +if [ -n "$GITHUB_TOKEN" ]; then + curl --fail -Lo "$package_name" -H "Authorization: token ${GITHUB_TOKEN}" "$package_url" +else + curl --fail -Lo "$package_name" "$package_url" +fi + +curl_exit_status=$? +if [ $curl_exit_status -ne 0 ]; then + exit $curl_exit_status +fi + +if command -v sudo >/dev/null 2>&1; then + package_install="sudo $package_install" +fi + +echo "$package_install $package_name" +sh -c "$package_install \"$package_name\"" +rm -f "$package_name" diff --git a/docs/installation/tools/rpm-install.sh b/docs/installation/tools/rpm-install.sh new file mode 100644 index 00000000..fc3d7314 --- /dev/null +++ b/docs/installation/tools/rpm-install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e -o pipefail + +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + 'x86_64') ARCH='amd64';; + 'x86' | 'i686' | 'i386') ARCH='386';; + 'aarch64' | 'arm64') ARCH='arm64';; + 'armv7l') ARCH='armv7';; + 's390x') ARCH='s390x';; + *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; +esac + +VERSION=$(curl -s https://api.github.com/repos/SagerNet/sing-box/releases/latest \ + | grep tag_name \ + | cut -d ":" -f2 \ + | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') + +curl -Lo sing-box.rpm "https://github.com/SagerNet/sing-box/releases/download/v${VERSION}/sing-box_${VERSION}_linux_${ARCH}.rpm" +sudo rpm -i sing-box.rpm +rm sing-box.rpm diff --git a/docs/installation/tools/sing-box.repo b/docs/installation/tools/sing-box.repo new file mode 100644 index 00000000..bf874825 --- /dev/null +++ b/docs/installation/tools/sing-box.repo @@ -0,0 +1,7 @@ +[sing-box] +name=sing-box +baseurl=https://rpm.sagernet.org/ +enabled=1 +repo_gpgcheck=1 +gpgcheck=1 +gpgkey=https://sing-box.app/gpg.key diff --git a/docs/manual/misc/tunnelvision.md b/docs/manual/misc/tunnelvision.md new file mode 100644 index 00000000..0d6caf76 --- /dev/null +++ b/docs/manual/misc/tunnelvision.md @@ -0,0 +1,38 @@ +--- +icon: material/book-lock-open +--- + +# TunnelVision + +TunnelVision is an attack that uses DHCP option 121 to set higher priority routes +so that traffic does not go through the VPN. + +Reference: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-3661 + +## Status + +### Android + +Android does not handle DHCP option 121 and is not affected. + +### Apple platforms + +Update [sing-box graphical client](/clients/apple/#download) to `1.9.0-rc.16` or newer, +then enable `includeAllNetworks` in `Settings` — `Packet Tunnel` and you will be unaffected. + +Note: when `includeAllNetworks` is enabled, the default TUN stack is changed to `gvisor`, +and the `system` and `mixed` stacks are not available. + +### Linux + +Update sing-box to `1.9.0-rc.16` or newer, rules generated by `auto-route` are unaffected. + +### Windows + +No solution yet. + +## Workarounds + +* Don't connect to untrusted networks +* Relay untrusted network through another device +* Just ignore it diff --git a/docs/manual/proxy-protocol/hysteria2.md b/docs/manual/proxy-protocol/hysteria2.md new file mode 100644 index 00000000..fe605b45 --- /dev/null +++ b/docs/manual/proxy-protocol/hysteria2.md @@ -0,0 +1,212 @@ +--- +icon: material/lightning-bolt +--- + +# Hysteria 2 + +Hysteria 2 is a simple, Chinese-made protocol based on QUIC. +The selling point is Brutal, a congestion control algorithm that +tries to achieve a user-defined bandwidth despite packet loss. + +!!! warning + + Even though GFW rarely blocks UDP-based proxies, such protocols actually have far more obvious characteristics than TCP based proxies. + +| Specification | Resists passive detection | Resists active probes | +|---------------------------------------------------------------------------|---------------------------|-----------------------| +| [hysteria.network](https://v2.hysteria.network/docs/developers/Protocol/) | :material-alert: | :material-check: | + +## :material-text-box-check: Password Generator + +| Generate Password | Action | +|----------------------------|-----------------------------------------------------------------| +| | | + + + +## :material-alert: Difference from official Hysteria + +The official program supports an authentication method called **userpass**, +which essentially uses a combination of `:` as the actual password, +while sing-box does not provide this alias. +To use sing-box with the official program, you need to fill in that combination as the actual password. + +## :material-server: Server Example + +!!! info "" + + Replace `up_mbps` and `down_mbps` values with the actual bandwidth of your server. + +=== ":material-harddisk: With local certificate" + + ```json + { + "inbounds": [ + { + "type": "hysteria2", + "listen": "::", + "listen_port": 8080, + "up_mbps": 100, + "down_mbps": 100, + "users": [ + { + "name": "sekai", + "password": "" + } + ], + "tls": { + "enabled": true, + "server_name": "example.org", + "key_path": "/path/to/key.pem", + "certificate_path": "/path/to/certificate.pem" + } + } + ] + } + ``` + +=== ":material-auto-fix: With ACME" + + ```json + { + "inbounds": [ + { + "type": "hysteria2", + "listen": "::", + "listen_port": 8080, + "up_mbps": 100, + "down_mbps": 100, + "users": [ + { + "name": "sekai", + "password": "" + } + ], + "tls": { + "enabled": true, + "server_name": "example.org", + "acme": { + "domain": "example.org", + "email": "admin@example.org" + } + } + } + ] + } + ``` + +=== ":material-cloud: With ACME and Cloudflare API" + + ```json + { + "inbounds": [ + { + "type": "hysteria2", + "listen": "::", + "listen_port": 8080, + "up_mbps": 100, + "down_mbps": 100, + "users": [ + { + "name": "sekai", + "password": "" + } + ], + "tls": { + "enabled": true, + "server_name": "example.org", + "acme": { + "domain": "example.org", + "email": "admin@example.org", + "dns01_challenge": { + "provider": "cloudflare", + "api_token": "my_token" + } + } + } + } + ] + } + ``` + +## :material-cellphone-link: Client Example + +!!! info "" + + Replace `up_mbps` and `down_mbps` values with the actual bandwidth of your client. + +=== ":material-web-check: With valid certificate" + + ```json + { + "outbounds": [ + { + "type": "hysteria2", + "server": "127.0.0.1", + "server_port": 8080, + "up_mbps": 100, + "down_mbps": 100, + "password": "", + "tls": { + "enabled": true, + "server_name": "example.org" + } + } + ] + } + ``` + +=== ":material-check: With self-sign certificate" + + !!! info "Tip" + + Use `sing-box merge` command to merge configuration and certificate into one file. + + ```json + { + "outbounds": [ + { + "type": "hysteria2", + "server": "127.0.0.1", + "server_port": 8080, + "up_mbps": 100, + "down_mbps": 100, + "password": "", + "tls": { + "enabled": true, + "server_name": "example.org", + "certificate_path": "/path/to/certificate.pem" + } + } + ] + } + ``` + +=== ":material-alert: Ignore certificate verification" + + ```json + { + "outbounds": [ + { + "type": "hysteria2", + "server": "127.0.0.1", + "server_port": 8080, + "up_mbps": 100, + "down_mbps": 100, + "password": "", + "tls": { + "enabled": true, + "server_name": "example.org", + "insecure": true + } + } + ] + } + ``` diff --git a/docs/manual/proxy-protocol/shadowsocks.md b/docs/manual/proxy-protocol/shadowsocks.md new file mode 100644 index 00000000..19f9d4cf --- /dev/null +++ b/docs/manual/proxy-protocol/shadowsocks.md @@ -0,0 +1,125 @@ +--- +icon: material/send +--- + +# Shadowsocks + +Shadowsocks is the most well-known Chinese-made proxy protocol. +It exists in multiple versions, but only AEAD 2022 ciphers +over TCP with multiplexing is recommended. + +| Ciphers | Specification | Cryptographically sound | Resists passive detection | Resists active probes | +|----------------|------------------------------------------------------------|-------------------------|---------------------------|-----------------------| +| Stream Ciphers | [shadowsocks.org](https://shadowsocks.org/doc/stream.html) | :material-alert: | :material-alert: | :material-alert: | +| AEAD | [shadowsocks.org](https://shadowsocks.org/doc/aead.html) | :material-check: | :material-alert: | :material-alert: | +| AEAD 2022 | [shadowsocks.org](https://shadowsocks.org/doc/sip022.html) | :material-check: | :material-check: | :material-help: | + +(We strongly recommend using multiplexing to send UDP traffic over TCP, because +doing otherwise is vulnerable to passive detection.) + +## :material-text-box-check: Password Generator + +| For `2022-blake3-aes-128-gcm` cipher | For other ciphers | Action | +|--------------------------------------|-------------------------------|-----------------------------------------------------------------| +| | | | + + + +## :material-server: Server Example + +=== ":material-account: Single-user" + + ```json + { + "inbounds": [ + { + "type": "shadowsocks", + "listen": "::", + "listen_port": 8080, + "network": "tcp", + "method": "2022-blake3-aes-128-gcm", + "password": "", + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +=== ":material-account-multiple: Multi-user" + + ```json + { + "inbounds": [ + { + "type": "shadowsocks", + "listen": "::", + "listen_port": 8080, + "network": "tcp", + "method": "2022-blake3-aes-128-gcm", + "password": "", + "users": [ + { + "name": "sekai", + "password": "" + } + ], + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +## :material-cellphone-link: Client Example + +=== ":material-account: Single-user" + + ```json + { + "outbounds": [ + { + "type": "shadowsocks", + "server": "127.0.0.1", + "server_port": 8080, + "method": "2022-blake3-aes-128-gcm", + "password": "", + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +=== ":material-account-multiple: Multi-user" + + ```json + { + "outbounds": [ + { + "type": "shadowsocks", + "server": "127.0.0.1", + "server_port": 8080, + "method": "2022-blake3-aes-128-gcm", + "password": ":", + "multiplex": { + "enabled": true + } + } + ] + } + ``` diff --git a/docs/manual/proxy-protocol/trojan.md b/docs/manual/proxy-protocol/trojan.md new file mode 100644 index 00000000..2bd63f5c --- /dev/null +++ b/docs/manual/proxy-protocol/trojan.md @@ -0,0 +1,200 @@ +--- +icon: material/horse +--- + +# Trojan + +Trojan is the most commonly used TLS proxy made in China. It can be used in various combinations. + +| Protocol and implementation combination | Specification | Resists passive detection | Resists active probes | +|-----------------------------------------|----------------------------------------------------------------------|---------------------------|-----------------------| +| Origin / trojan-gfw | [trojan-gfw.github.io](https://trojan-gfw.github.io/trojan/protocol) | :material-check: | :material-check: | +| Basic Go implementation | / | :material-alert: | :material-check: | +| with privates transport by V2Ray | No formal definition | :material-alert: | :material-alert: | +| with uTLS enabled | No formal definition | :material-help: | :material-check: | + +## :material-text-box-check: Password Generator + +| Generate Password | Action | +|----------------------------|-----------------------------------------------------------------| +| | | + + + +## :material-server: Server Example + +=== ":material-harddisk: With local certificate" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "listen": "::", + "listen_port": 8080, + "users": [ + { + "name": "example", + "password": "password" + } + ], + "tls": { + "enabled": true, + "server_name": "example.org", + "key_path": "/path/to/key.pem", + "certificate_path": "/path/to/certificate.pem" + }, + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +=== ":material-auto-fix: With ACME" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "listen": "::", + "listen_port": 8080, + "users": [ + { + "name": "example", + "password": "password" + } + ], + "tls": { + "enabled": true, + "server_name": "example.org", + "acme": { + "domain": "example.org", + "email": "admin@example.org" + } + }, + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +=== ":material-cloud: With ACME and Cloudflare API" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "listen": "::", + "listen_port": 8080, + "users": [ + { + "name": "example", + "password": "password" + } + ], + "tls": { + "enabled": true, + "server_name": "example.org", + "acme": { + "domain": "example.org", + "email": "admin@example.org", + "dns01_challenge": { + "provider": "cloudflare", + "api_token": "my_token" + } + } + }, + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +## :material-cellphone-link: Client Example + +=== ":material-web-check: With valid certificate" + + ```json + { + "outbounds": [ + { + "type": "trojan", + "server": "127.0.0.1", + "server_port": 8080, + "password": "password", + "tls": { + "enabled": true, + "server_name": "example.org" + }, + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +=== ":material-check: With self-sign certificate" + + !!! info "Tip" + + Use `sing-box merge` command to merge configuration and certificate into one file. + + ```json + { + "outbounds": [ + { + "type": "trojan", + "server": "127.0.0.1", + "server_port": 8080, + "password": "password", + "tls": { + "enabled": true, + "server_name": "example.org", + "certificate_path": "/path/to/certificate.pem" + }, + "multiplex": { + "enabled": true + } + } + ] + } + ``` + +=== ":material-alert: Ignore certificate verification" + + ```json + { + "outbounds": [ + { + "type": "trojan", + "server": "127.0.0.1", + "server_port": 8080, + "password": "password", + "tls": { + "enabled": true, + "server_name": "example.org", + "insecure": true + }, + "multiplex": { + "enabled": true + } + } + ] + } + ``` diff --git a/docs/manual/proxy/client.md b/docs/manual/proxy/client.md new file mode 100644 index 00000000..e8c80523 --- /dev/null +++ b/docs/manual/proxy/client.md @@ -0,0 +1,503 @@ +--- +icon: material/cellphone-link +--- + +# Client + +### :material-ray-start: Introduction + +For a long time, the modern usage and principles of proxy clients +for graphical operating systems have not been clearly described. +However, we can categorize them into three types: +system proxy, firewall redirection, and virtual interface. + +### :material-web-refresh: System Proxy + +Almost all graphical environments support system-level proxies, +which are essentially ordinary HTTP proxies that only support TCP. + +| Operating System / Desktop Environment | System Proxy | Application Support | +|:---------------------------------------------|:-------------------------------------|:--------------------| +| Windows | :material-check: | :material-check: | +| macOS | :material-check: | :material-check: | +| GNOME/KDE | :material-check: | :material-check: | +| Android | ROOT or adb (permission) is required | :material-check: | +| Android/iOS (with sing-box graphical client) | via `tun.platform.http_proxy` | :material-check: | + +As one of the most well-known proxy methods, it has many shortcomings: +many TCP clients that are not based on HTTP do not check and use the system proxy. +Moreover, UDP and ICMP traffics bypass the proxy. + +```mermaid +flowchart LR + dns[DNS query] -- Is HTTP request? --> proxy[HTTP proxy] + dns --> leak[Leak] + tcp[TCP connection] -- Is HTTP request? --> proxy + tcp -- Check and use HTTP CONNECT? --> proxy + tcp --> leak + udp[UDP packet] --> leak +``` + +### :material-wall-fire: Firewall Redirection + +This type of usage typically relies on the firewall or hook interface provided by the operating system, +such as Windows’ WFP, Linux’s redirect, TProxy and eBPF, and macOS’s pf. +Although it is intrusive and cumbersome to configure, +it remains popular within the community of amateur proxy open source projects like V2Ray, +due to the low technical requirements it imposes on the software. + +### :material-expansion-card: Virtual Interface + +All L2/L3 proxies (seriously defined VPNs, such as OpenVPN, WireGuard) are based on virtual network interfaces, +which is also the only way for all L4 proxies to work as VPNs on mobile platforms like Android, iOS. + +The sing-box inherits and develops clash-premium’s TUN inbound (L3 to L4 conversion) +as the most reasonable method for performing transparent proxying. + +```mermaid +flowchart TB + packet[IP Packet] + packet --> windows[Windows / macOS] + packet --> linux[Linux] + tun[TUN interface] + windows -. route .-> tun + linux -. iproute2 route/rule .-> tun + tun --> gvisor[gVisor TUN stack] + tun --> system[system TUN stack] + assemble([L3 to L4 assemble]) + gvisor --> assemble + system --> assemble + assemble --> conn[TCP and UDP connections] + conn --> router[sing-box Router] + router --> direct[Direct outbound] + router --> proxy[Proxy outbounds] + router -- DNS hijack --> dns_out[DNS outbound] + dns_out --> dns_router[DNS router] + dns_router --> router + direct --> adi([auto detect interface]) + proxy --> adi + adi --> default[Default network interface in the system] + default --> destination[Destination server] + default --> proxy_server[Proxy server] + proxy_server --> destination +``` + +## :material-cellphone-link: Examples + +### Basic TUN usage for Chinese users + +=== ":material-numeric-4-box: IPv4 only" + + ```json + { + "dns": { + "servers": [ + { + "tag": "google", + "type": "tls", + "server": "8.8.8.8" + }, + { + "tag": "local", + "type": "udp", + "server": "223.5.5.5" + } + ], + "strategy": "ipv4_only" + }, + "inbounds": [ + { + "type": "tun", + "address": ["172.19.0.1/30"], + "auto_route": true, + // "auto_redirect": true, // On linux + "strict_route": true + } + ], + "outbounds": [ + // ... + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + }, + { + "ip_is_private": true, + "outbound": "direct" + } + ], + "default_domain_resolver": "local", + "auto_detect_interface": true + } + } + ``` + +=== ":material-numeric-6-box: IPv4 & IPv6" + + ```json + { + "dns": { + "servers": [ + { + "tag": "google", + "type": "tls", + "server": "8.8.8.8" + }, + { + "tag": "local", + "type": "udp", + "server": "223.5.5.5" + } + ] + }, + "inbounds": [ + { + "type": "tun", + "address": ["172.19.0.1/30", "fdfe:dcba:9876::1/126"], + "auto_route": true, + // "auto_redirect": true, // On linux + "strict_route": true + } + ], + "outbounds": [ + // ... + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + }, + { + "ip_is_private": true, + "outbound": "direct" + } + ], + "default_domain_resolver": "local", + "auto_detect_interface": true + } + } + ``` + +=== ":material-domain-switch: FakeIP" + + ```json + { + "dns": { + "servers": [ + { + "tag": "google", + "type": "tls", + "server": "8.8.8.8" + }, + { + "tag": "local", + "type": "udp", + "server": "223.5.5.5" + }, + { + "tag": "remote", + "type": "fakeip", + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + ], + "rules": [ + { + "query_type": [ + "A", + "AAAA" + ], + "server": "remote" + } + ], + "independent_cache": true + }, + "inbounds": [ + { + "type": "tun", + "address": ["172.19.0.1/30","fdfe:dcba:9876::1/126"], + "auto_route": true, + // "auto_redirect": true, // On linux + "strict_route": true + } + ], + "outbounds": [ + // ... + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + }, + { + "ip_is_private": true, + "outbound": "direct" + } + ], + "default_domain_resolver": "local", + "auto_detect_interface": true + } + } + ``` + +### Traffic bypass usage for Chinese users + +=== ":material-dns: DNS rules" + + === ":material-shield-off: With DNS leaks" + + ```json + { + "dns": { + "servers": [ + { + "tag": "google", + "type": "tls", + "server": "8.8.8.8" + }, + { + "tag": "local", + "type": "https", + "server": "223.5.5.5" + } + ], + "rules": [ + { + "rule_set": "geosite-geolocation-cn", + "server": "local" + }, + { + "type": "logical", + "mode": "and", + "rules": [ + { + "rule_set": "geosite-geolocation-!cn", + "invert": true + }, + { + "rule_set": "geoip-cn" + } + ], + "server": "local" + } + ] + }, + "route": { + "default_domain_resolver": "local", + "rule_set": [ + { + "type": "remote", + "tag": "geosite-geolocation-cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs" + }, + { + "type": "remote", + "tag": "geosite-geolocation-!cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs" + }, + { + "type": "remote", + "tag": "geoip-cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + }, + "clash_api": { + "default_mode": "Enhanced" + } + } + } + ``` + + === ":material-security: Without DNS leaks, but slower" + + ```json + { + "dns": { + "servers": [ + { + "tag": "google", + "type": "tls", + "server": "8.8.8.8" + }, + { + "tag": "local", + "type": "https", + "server": "223.5.5.5" + } + ], + "rules": [ + { + "rule_set": "geosite-geolocation-cn", + "server": "local" + }, + { + "type": "logical", + "mode": "and", + "rules": [ + { + "rule_set": "geosite-geolocation-!cn", + "invert": true + }, + { + "rule_set": "geoip-cn" + } + ], + "server": "google", + "client_subnet": "114.114.114.114/24" // Any China client IP address + } + ] + }, + "route": { + "default_domain_resolver": "local", + "rule_set": [ + { + "type": "remote", + "tag": "geosite-geolocation-cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs" + }, + { + "type": "remote", + "tag": "geosite-geolocation-!cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs" + }, + { + "type": "remote", + "tag": "geoip-cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + }, + "clash_api": { + "default_mode": "Enhanced" + } + } + } + ``` + +=== ":material-router-network: Route rules" + + ```json + { + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "type": "logical", + "mode": "or", + "rules": [ + { + "protocol": "dns" + }, + { + "port": 53 + } + ], + "action": "hijack-dns" + }, + { + "ip_is_private": true, + "outbound": "direct" + }, + { + "type": "logical", + "mode": "or", + "rules": [ + { + "port": 853 + }, + { + "network": "udp", + "port": 443 + }, + { + "protocol": "stun" + } + ], + "action": "reject" + }, + { + "rule_set": "geosite-geolocation-cn", + "outbound": "direct" + }, + { + "type": "logical", + "mode": "and", + "rules": [ + { + "rule_set": "geoip-cn" + }, + { + "rule_set": "geosite-geolocation-!cn", + "invert": true + } + ], + "outbound": "direct" + } + ], + "rule_set": [ + { + "type": "remote", + "tag": "geoip-cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" + }, + { + "type": "remote", + "tag": "geosite-geolocation-cn", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs" + } + ] + } + } + ``` diff --git a/docs/manual/proxy/server.md b/docs/manual/proxy/server.md new file mode 100644 index 00000000..4d2667e0 --- /dev/null +++ b/docs/manual/proxy/server.md @@ -0,0 +1,10 @@ +--- +icon: material/server +--- + +# Server + +To use sing-box as a proxy protocol server, you pretty much only need to configure the inbound for that protocol. + +The Proxy Protocol menu below contains descriptions and configuration examples +of recommended protocols for bypassing GFW. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 00000000..867f903b --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,1438 @@ +--- +icon: material/arrange-bring-forward +--- + +## 1.14.0 + +### Migrate inline ACME to certificate provider + +Inline ACME options in TLS are deprecated and can be replaced by certificate providers. + +Most `tls.acme` fields can be moved into the ACME certificate provider unchanged. +See [ACME](/configuration/shared/certificate-provider/acme/) for fields newly added in sing-box 1.14.0. + +!!! info "References" + + [TLS](/configuration/shared/tls/#certificate_provider) / + [Certificate Provider](/configuration/shared/certificate-provider/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "acme": { + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: Inline" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": { + "type": "acme", + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: Shared" + + ```json + { + "certificate_providers": [ + { + "type": "acme", + "tag": "my-cert", + "domain": ["example.com"], + "email": "admin@example.com" + } + ], + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": "my-cert" + } + } + ] + } + ``` + +### Migrate address filter fields to response matching + +Legacy Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) in DNS rules are deprecated, +along with the Legacy `rule_set_ip_cidr_accept_empty` DNS rule item. + +In sing-box 1.14.0, use the [`evaluate`](/configuration/dns/rule_action/#evaluate) action +to fetch a DNS response, then match against it explicitly with `match_response`. + +!!! info "References" + + [DNS Rule](/configuration/dns/rule/) / + [DNS Rule Action](/configuration/dns/rule_action/#evaluate) + +=== ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "rules": [ + { + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "dns": { + "rules": [ + { + "action": "evaluate", + "server": "remote" + }, + { + "match_response": true, + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + +### Migrate independent DNS cache + +The DNS cache now always keys by transport name, making `independent_cache` unnecessary. +Simply remove the field. + +!!! info "References" + + [DNS](/configuration/dns/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "independent_cache": true + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "dns": {} + } + ``` + +### Migrate store_rdrc + +`store_rdrc` is deprecated and can be replaced by `store_dns`, +which persists the full DNS cache to the cache file. + +!!! info "References" + + [Cache File](/configuration/experimental/cache-file/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "experimental": { + "cache_file": { + "enabled": true, + "store_dns": true + } + } + } + ``` + +### ip_version and query_type behavior changes in DNS rules + +In sing-box 1.14.0, the behavior of +[`ip_version`](/configuration/dns/rule/#ip_version) and +[`query_type`](/configuration/dns/rule/#query_type) in DNS rules, together with +[`query_type`](/configuration/rule-set/headless-rule/#query_type) in referenced +rule-sets, changes in two ways. + +First, these fields now take effect on every DNS rule evaluation. In earlier +versions they were evaluated only for DNS queries received from a client +(for example, from a DNS inbound or intercepted by `tun`), and were silently +ignored when a DNS rule was matched from an internal domain resolution that +did not target a specific DNS server. Such internal resolutions include: + +- The [`resolve`](/configuration/route/rule_action/#resolve) route rule + action without a `server` set. +- ICMP traffic routed to a domain destination through a `direct` outbound. +- A [WireGuard](/configuration/endpoint/wireguard/) or + [Tailscale](/configuration/endpoint/tailscale/) endpoint used as an + outbound, when resolving its own destination address. +- A [SOCKS4](/configuration/outbound/socks/) outbound, which must resolve + the destination locally because the protocol has no in-protocol domain + support. +- The [DERP](/configuration/service/derp/) `bootstrap-dns` endpoint and the + [`resolved`](/configuration/service/resolved/) service (when resolving a + hostname or an SRV target). + +Resolutions that target a specific DNS server — via +[`domain_resolver`](/configuration/shared/dial/#domain_resolver) on a dial +field, [`default_domain_resolver`](/configuration/route/#default_domain_resolver) +in route options, or an explicit `server` on a DNS rule action or the +`resolve` route rule action — do not go through DNS rule matching and are +unaffected. + +Second, setting `ip_version` or `query_type` in a DNS rule, or referencing a +rule-set containing `query_type`, is no longer compatible in the same DNS +configuration with Legacy Address Filter Fields in DNS rules, the Legacy +`strategy` DNS rule action option, or the Legacy `rule_set_ip_cidr_accept_empty` +DNS rule item. Such a configuration will be rejected at startup. To combine +these fields with address-based filtering, migrate to response matching via +the [`evaluate`](/configuration/dns/rule_action/#evaluate) action and +[`match_response`](/configuration/dns/rule/#match_response), see +[Migrate address filter fields to response matching](#migrate-address-filter-fields-to-response-matching). + +!!! info "References" + + [DNS Rule](/configuration/dns/rule/) / + [Headless Rule](/configuration/rule-set/headless-rule/) / + [Route Rule Action](/configuration/route/rule_action/#resolve) + +## 1.12.0 + +### Migrate to new DNS server formats + +DNS servers are refactored for better performance and scalability. + +!!! info "References" + + [DNS Server](/configuration/dns/server/) / + [Legacy DNS Server](/configuration/dns/server/legacy/) + +=== "Local" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "local" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "local" + } + ] + } + } + ``` + +=== "TCP" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "tcp://1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "tcp", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "UDP" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "TLS" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "tls://1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "tls", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "HTTPS" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "https://1.1.1.1/dns-query" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "https", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "QUIC" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "quic://1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "quic", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "HTTP3" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "h3://1.1.1.1/dns-query" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "h3", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "DHCP" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "dhcp://auto" + }, + { + "address": "dhcp://en0" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "dhcp", + }, + { + "type": "dhcp", + "interface": "en0" + } + ] + } + } + ``` + +=== "FakeIP" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1" + }, + { + "address": "fakeip", + "tag": "fakeip" + } + ], + "rules": [ + { + "query_type": [ + "A", + "AAAA" + ], + "server": "fakeip" + } + ], + "fakeip": { + "enabled": true, + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + }, + { + "type": "fakeip", + "tag": "fakeip", + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + ], + "rules": [ + { + "query_type": [ + "A", + "AAAA" + ], + "server": "fakeip" + } + ] + } + } + ``` + +=== "RCode" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "rcode://refused" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "rules": [ + { + "domain": [ + "example.com" + ], + // other rules + + "action": "predefined", + "rcode": "REFUSED" + } + ] + } + } + ``` + +=== "Servers with domain address" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "https://dns.google/dns-query", + "address_resolver": "google" + }, + { + "tag": "google", + "address": "1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "https", + "server": "dns.google", + "domain_resolver": "google" + }, + { + "type": "udp", + "tag": "google", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "Servers with strategy" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1", + "strategy": "ipv4_only" + }, + { + "tag": "google", + "address": "8.8.8.8", + "strategy": "prefer_ipv6" + } + ], + "rules": [ + { + "domain": "google.com", + "server": "google" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + }, + { + "type": "udp", + "tag": "google", + "server": "8.8.8.8" + } + ], + "rules": [ + { + "domain": "google.com", + "server": "google", + "strategy": "prefer_ipv6" + } + ], + "strategy": "ipv4_only" + } + } + ``` + +=== "Servers with client subnet" + + === ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1" + }, + { + "tag": "google", + "address": "8.8.8.8", + "client_subnet": "1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + }, + { + "type": "udp", + "tag": "google", + "server": "8.8.8.8" + } + ], + "rules": [ + { + "domain": "google.com", + "server": "google", + "client_subnet": "1.1.1.1" + } + ] + } + } + ``` + +### Migrate outbound DNS rule items to domain resolver + +The legacy outbound DNS rules are deprecated and can be replaced by new domain resolver options. + +!!! info "References" + + [DNS rule](/configuration/dns/rule/#outbound) / + [Dial Fields](/configuration/shared/dial/#domain_resolver) / + [Route](/configuration/route/#domain_resolver) + +=== ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "servers": [ + { + "address": "local", + "tag": "local" + } + ], + "rules": [ + { + "outbound": "any", + "server": "local" + } + ] + }, + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080 + } + ] + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + } + ] + }, + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080, + "domain_resolver": { + "server": "local", + "rewrite_ttl": 60, + "client_subnet": "1.1.1.1" + }, + // or "domain_resolver": "local", + } + ], + + // or + + "route": { + "default_domain_resolver": { + "server": "local", + "rewrite_ttl": 60, + "client_subnet": "1.1.1.1" + } + } + } + ``` + +### Migrate outbound domain strategy option to domain resolver + +!!! info "References" + + [Dial Fields](/configuration/shared/dial/#domain_strategy) + +The `domain_strategy` option in Dial Fields has been deprecated and can be replaced with the new domain resolver option. + +Note that due to the use of Dial Fields by some of the new DNS servers introduced in sing-box 1.12, +some people mistakenly believe that `domain_strategy` is the same feature as in the legacy DNS servers. + +=== ":material-card-remove: Deprecated" + + ```json + { + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080, + "domain_strategy": "prefer_ipv4", + } + ] + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + } + ] + }, + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080, + "domain_resolver": { + "server": "local", + "strategy": "prefer_ipv4" + } + } + ] + } + ``` + +## 1.11.0 + +### Migrate legacy special outbounds to rule actions + +Legacy special outbounds are deprecated and can be replaced by rule actions. + +!!! info "References" + + [Rule Action](/configuration/route/rule_action/) / + [Block](/configuration/outbound/block/) / + [DNS](/configuration/outbound/dns) + +=== "Block" + + === ":material-card-remove: Deprecated" + + ```json + { + "outbounds": [ + { + "type": "block", + "tag": "block" + } + ], + "route": { + "rules": [ + { + ..., + + "outbound": "block" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + ..., + + "action": "reject" + } + ] + } + } + ``` + +=== "DNS" + + === ":material-card-remove: Deprecated" + + ```json + { + "inbound": [ + { + ..., + + "sniff": true + } + ], + "outbounds": [ + { + "tag": "dns", + "type": "dns" + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "outbound": "dns" + } + ] + } + } + ``` + + === ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + } + ] + } + } + ``` + +### Migrate legacy inbound fields to rule actions + +Inbound fields are deprecated and can be replaced by rule actions. + +!!! info "References" + + [Listen Fields](/configuration/shared/listen/) / + [Rule](/configuration/route/rule/) / + [Rule Action](/configuration/route/rule_action/) / + [DNS Rule](/configuration/dns/rule/) / + [DNS Rule Action](/configuration/dns/rule_action/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "inbounds": [ + { + "type": "mixed", + "sniff": true, + "sniff_timeout": "1s", + "domain_strategy": "prefer_ipv4" + } + ] + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "inbounds": [ + { + "type": "mixed", + "tag": "in" + } + ], + "route": { + "rules": [ + { + "inbound": "in", + "action": "resolve", + "strategy": "prefer_ipv4" + }, + { + "inbound": "in", + "action": "sniff", + "timeout": "1s" + } + ] + } + } + ``` + +### Migrate destination override fields to route options + +Destination override fields in direct outbound are deprecated and can be replaced by route options. + +!!! info "References" + + [Rule Action](/configuration/route/rule_action/) / + [Direct](/configuration/outbound/direct/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "outbounds": [ + { + "type": "direct", + "override_address": "1.1.1.1", + "override_port": 443 + } + ] + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + "action": "route-options", // or route + "override_address": "1.1.1.1", + "override_port": 443 + } + ] + } + ``` + +### Migrate WireGuard outbound to endpoint + +WireGuard outbound is deprecated and can be replaced by endpoint. + +!!! info "References" + + [Endpoint](/configuration/endpoint/) / + [WireGuard Endpoint](/configuration/endpoint/wireguard/) / + [WireGuard Outbound](/configuration/outbound/wireguard/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "outbounds": [ + { + "type": "wireguard", + "tag": "wg-out", + + "server": "127.0.0.1", + "server_port": 10001, + "system_interface": true, + "gso": true, + "interface_name": "wg0", + "local_address": [ + "10.0.0.1/32" + ], + "private_key": "", + "peer_public_key": "", + "pre_shared_key": "", + "reserved": [0, 0, 0], + "mtu": 1408 + } + ] + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "endpoints": [ + { + "type": "wireguard", + "tag": "wg-ep", + "system": true, + "name": "wg0", + "mtu": 1408, + "address": [ + "10.0.0.2/32" + ], + "private_key": "", + "listen_port": 10000, + "peers": [ + { + "address": "127.0.0.1", + "port": 10001, + "public_key": "", + "pre_shared_key": "", + "allowed_ips": [ + "0.0.0.0/0" + ], + "persistent_keepalive_interval": 30, + "reserved": [0, 0, 0] + } + ] + } + ] + } + ``` + +## 1.10.0 + +### TUN address fields are merged + +`inet4_address` and `inet6_address` are merged into `address`, +`inet4_route_address` and `inet6_route_address` are merged into `route_address`, +`inet4_route_exclude_address` and `inet6_route_exclude_address` are merged into `route_exclude_address`. + +!!! info "References" + + [TUN](/configuration/inbound/tun/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "inbounds": [ + { + "type": "tun", + "inet4_address": "172.19.0.1/30", + "inet6_address": "fdfe:dcba:9876::1/126", + "inet4_route_address": [ + "0.0.0.0/1", + "128.0.0.0/1" + ], + "inet6_route_address": [ + "::/1", + "8000::/1" + ], + "inet4_route_exclude_address": [ + "192.168.0.0/16" + ], + "inet6_route_exclude_address": [ + "fc00::/7" + ] + } + ] + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "inbounds": [ + { + "type": "tun", + "address": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "route_address": [ + "0.0.0.0/1", + "128.0.0.0/1", + "::/1", + "8000::/1" + ], + "route_exclude_address": [ + "192.168.0.0/16", + "fc00::/7" + ] + } + ] + } + ``` + +## 1.9.5 + +### Bundle Identifier updates in Apple platform clients + +Due to problems with our old Apple developer account, +we can only change Bundle Identifiers to re-list sing-box apps, +which means the data will not be automatically inherited. + +For iOS, you need to back up your old data yourself (if you still have access to it); +for tvOS, you need to re-import profiles from your iPhone or iPad or create it manually; +for macOS, you can migrate the data folder using the following command: + +```bash +cd ~/Library/Group\ Containers && \ + mv group.io.nekohasekai.sfa group.io.nekohasekai.sfavt +``` + +## 1.9.0 + +### `domain_suffix` behavior update + +For historical reasons, sing-box's `domain_suffix` rule matches literal prefixes instead of the same as other projects. + +sing-box 1.9.0 modifies the behavior of `domain_suffix`: If the rule value is prefixed with `.`, +the behavior is unchanged, otherwise it matches `(domain|.+\.domain)` instead. + +### `process_path` format update on Windows + +The `process_path` rule of sing-box is inherited from Clash, +the original code uses the local system's path format (e.g. `\Device\HarddiskVolume1\folder\program.exe`), +but when the device has multiple disks, the HarddiskVolume serial number is not stable. + +sing-box 1.9.0 make QueryFullProcessImageNameW output a Win32 path (such as `C:\folder\program.exe`), +which will disrupt the existing `process_path` use cases in Windows. + +## 1.8.0 + +### :material-close-box: Migrate cache file from Clash API to independent options + +!!! info "References" + + [Clash API](/configuration/experimental/clash-api/) / + [Cache File](/configuration/experimental/cache-file/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "experimental": { + "clash_api": { + "cache_file": "cache.db", // default value + "cahce_id": "my_profile2", + "store_mode": true, + "store_selected": true, + "store_fakeip": true + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "experimental" : { + "cache_file": { + "enabled": true, + "path": "cache.db", // default value + "cache_id": "my_profile2", + "store_fakeip": true + } + } + } + ``` + +### :material-checkbox-intermediate: Migrate GeoIP to rule-sets + +!!! info "References" + + [GeoIP](/configuration/route/geoip/) / + [Route](/configuration/route/) / + [Route Rule](/configuration/route/rule/) / + [DNS Rule](/configuration/dns/rule/) / + [rule-set](/configuration/rule-set/) + +!!! tip + + `sing-box geoip` commands can help you convert custom GeoIP into rule-sets. + +=== ":material-card-remove: Deprecated" + + ```json + { + "route": { + "rules": [ + { + "geoip": "private", + "outbound": "direct" + }, + { + "geoip": "cn", + "outbound": "direct" + }, + { + "source_geoip": "cn", + "outbound": "block" + } + ], + "geoip": { + "download_detour": "proxy" + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + "ip_is_private": true, + "outbound": "direct" + }, + { + "rule_set": "geoip-cn", + "outbound": "direct" + }, + { + "rule_set": "geoip-us", + "rule_set_ipcidr_match_source": true, + "outbound": "block" + } + ], + "rule_set": [ + { + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": "proxy" + }, + { + "tag": "geoip-us", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-us.srs", + "download_detour": "proxy" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true // required to save rule-set cache + } + } + } + ``` + +### :material-checkbox-intermediate: Migrate Geosite to rule-sets + +!!! info "References" + + [Geosite](/configuration/route/geosite/) / + [Route](/configuration/route/) / + [Route Rule](/configuration/route/rule/) / + [DNS Rule](/configuration/dns/rule/) / + [rule-set](/configuration/rule-set/) + +!!! tip + + `sing-box geosite` commands can help you convert custom Geosite into rule-sets. + +=== ":material-card-remove: Deprecated" + + ```json + { + "route": { + "rules": [ + { + "geosite": "cn", + "outbound": "direct" + } + ], + "geosite": { + "download_detour": "proxy" + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + "rule_set": "geosite-cn", + "outbound": "direct" + } + ], + "rule_set": [ + { + "tag": "geosite-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", + "download_detour": "proxy" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true // required to save rule-set cache + } + } + } + ``` diff --git a/docs/migration.zh.md b/docs/migration.zh.md new file mode 100644 index 00000000..54dec47e --- /dev/null +++ b/docs/migration.zh.md @@ -0,0 +1,1427 @@ +--- +icon: material/arrange-bring-forward +--- + +## 1.14.0 + +### 迁移内联 ACME 到证书提供者 + +TLS 中的内联 ACME 选项已废弃,且可以被证书提供者替代。 + +`tls.acme` 的大多数字段都可以原样迁移到 ACME 证书提供者中。 +sing-box 1.14.0 新增字段参阅 [ACME](/zh/configuration/shared/certificate-provider/acme/) 页面。 + +!!! info "参考" + + [TLS](/zh/configuration/shared/tls/#certificate_provider) / + [证书提供者](/zh/configuration/shared/certificate-provider/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "acme": { + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: 内联" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": { + "type": "acme", + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: 共享" + + ```json + { + "certificate_providers": [ + { + "type": "acme", + "tag": "my-cert", + "domain": ["example.com"], + "email": "admin@example.com" + } + ], + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": "my-cert" + } + } + ] + } + ``` + +### 迁移地址筛选字段到响应匹配 + +旧版地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项也已废弃。 + +在 sing-box 1.14.0 中,请使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作 +获取 DNS 响应,然后通过 `match_response` 显式匹配。 + +!!! info "参考" + + [DNS 规则](/zh/configuration/dns/rule/) / + [DNS 规则动作](/zh/configuration/dns/rule_action/#evaluate) + +=== ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "rules": [ + { + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "dns": { + "rules": [ + { + "action": "evaluate", + "server": "remote" + }, + { + "match_response": true, + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + +### 迁移 independent DNS cache + +DNS 缓存现在始终按传输名称分离,使 `independent_cache` 不再需要。 +直接移除该字段即可。 + +!!! info "参考" + + [DNS](/zh/configuration/dns/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "independent_cache": true + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "dns": {} + } + ``` + +### 迁移 store_rdrc + +`store_rdrc` 已废弃,且可以被 `store_dns` 替代, +后者将完整的 DNS 缓存持久化到缓存文件中。 + +!!! info "参考" + + [缓存文件](/zh/configuration/experimental/cache-file/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + } + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "experimental": { + "cache_file": { + "enabled": true, + "store_dns": true + } + } + } + ``` + +### DNS 规则中的 ip_version 和 query_type 行为更改 + +在 sing-box 1.14.0 中,DNS 规则中的 +[`ip_version`](/zh/configuration/dns/rule/#ip_version) 和 +[`query_type`](/zh/configuration/dns/rule/#query_type),以及被引用规则集中的 +[`query_type`](/zh/configuration/rule-set/headless-rule/#query_type), +行为有两项更改。 + +其一,这些字段现在对每一次 DNS 规则评估都会生效。此前它们仅对来自客户端的 DNS 查询 +(例如来自 DNS 入站或被 `tun` 截获的查询)生效,当 DNS 规则被未指定具体 DNS 服务器的 +内部域名解析匹配时,会被静默忽略。此类内部解析包括: + +- 未设置 `server` 的 [`resolve`](/zh/configuration/route/rule_action/#resolve) 路由规则动作。 +- 通过 `direct` 出站路由到域名目标的 ICMP 流量。 +- 作为出站使用的 [WireGuard](/zh/configuration/endpoint/wireguard/) 或 + [Tailscale](/zh/configuration/endpoint/tailscale/) 端点在解析自身目标地址时。 +- [SOCKS4](/zh/configuration/outbound/socks/) 出站,因为协议本身不支持域名, + 必须在本地解析目标。 +- [DERP](/zh/configuration/service/derp/) 的 `bootstrap-dns` 端点,以及 + [`resolved`](/zh/configuration/service/resolved/) 服务在解析主机名或 SRV 目标时。 + +通过拨号字段中的 +[`domain_resolver`](/zh/configuration/shared/dial/#domain_resolver)、 +路由选项中的 [`default_domain_resolver`](/zh/configuration/route/#default_domain_resolver), +或 DNS 规则动作与 `resolve` 路由规则动作上显式的 `server` 指定具体 DNS 服务器的 +解析,不会经过 DNS 规则匹配,不受此次更改影响。 + +其二,设置了 `ip_version` 或 `query_type` 的 DNS 规则,或引用了包含 `query_type` 的 +规则集的 DNS 规则,在同一 DNS 配置中不再能与旧版地址筛选字段 (DNS 规则)、旧版 +DNS 规则动作 `strategy` 选项,或旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项共存。 +此类配置将在启动时被拒绝。如需将这些字段与基于地址的筛选组合,请通过 +[`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作和 +[`match_response`](/zh/configuration/dns/rule/#match_response) 迁移到响应匹配, +参阅 [迁移地址筛选字段到响应匹配](#迁移地址筛选字段到响应匹配)。 + +!!! info "参考" + + [DNS 规则](/zh/configuration/dns/rule/) / + [Headless 规则](/zh/configuration/rule-set/headless-rule/) / + [路由规则动作](/zh/configuration/route/rule_action/#resolve) + +## 1.12.0 + +### 迁移到新的 DNS 服务器格式 + +DNS 服务器已经重构。 + +!!! info "引用" + + [DNS 服务器](/zh/configuration/dns/server/) / + [旧 DNS 服务器](/zh/configuration/dns/server/legacy/) + +=== "Local" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "local" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "local" + } + ] + } + } + ``` + +=== "TCP" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "tcp://1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "tcp", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "UDP" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "TLS" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "tls://1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "tls", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "HTTPS" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "https://1.1.1.1/dns-query" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "https", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "QUIC" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "quic://1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "quic", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "HTTP3" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "h3://1.1.1.1/dns-query" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "h3", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "DHCP" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "dhcp://auto" + }, + { + "address": "dhcp://en0" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "dhcp", + }, + { + "type": "dhcp", + "interface": "en0" + } + ] + } + } + ``` + +=== "FakeIP" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1" + }, + { + "address": "fakeip", + "tag": "fakeip" + } + ], + "rules": [ + { + "query_type": [ + "A", + "AAAA" + ], + "server": "fakeip" + } + ], + "fakeip": { + "enabled": true, + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + }, + { + "type": "fakeip", + "tag": "fakeip", + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + } + ], + "rules": [ + { + "query_type": [ + "A", + "AAAA" + ], + "server": "fakeip" + } + ] + } + } + ``` + +=== "RCode" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "rcode://refused" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "rules": [ + { + "domain": [ + "example.com" + ], + // 其它规则 + + "action": "predefined", + "rcode": "REFUSED" + } + ] + } + } + ``` + +=== "带有域名地址的服务器" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "https://dns.google/dns-query", + "address_resolver": "google" + }, + { + "tag": "google", + "address": "1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "https", + "server": "dns.google", + "domain_resolver": "google" + }, + { + "type": "udp", + "tag": "google", + "server": "1.1.1.1" + } + ] + } + } + ``` + +=== "带有域策略的服务器" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1", + "strategy": "ipv4_only" + }, + { + "tag": "google", + "address": "8.8.8.8", + "strategy": "prefer_ipv6" + } + ], + "rules": [ + { + "domain": "google.com", + "server": "google" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + }, + { + "type": "udp", + "tag": "google", + "server": "8.8.8.8" + } + ], + "rules": [ + { + "domain": "google.com", + "server": "google", + "strategy": "prefer_ipv6" + } + ], + "strategy": "ipv4_only" + } + } + ``` + +=== "带有客户端子网的服务器" + + === ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "servers": [ + { + "address": "1.1.1.1" + }, + { + "tag": "google", + "address": "8.8.8.8", + "client_subnet": "1.1.1.1" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "udp", + "server": "1.1.1.1" + }, + { + "type": "udp", + "tag": "google", + "server": "8.8.8.8" + } + ], + "rules": [ + { + "domain": "google.com", + "server": "google", + "client_subnet": "1.1.1.1" + } + ] + } + } + ``` + +### 迁移 outbound DNS 规则项到域解析选项 + +旧的 `outbound` DNS 规则已废弃,且可新的域解析选项代替。 + +!!! info "参考" + + [DNS 规则](/zh/configuration/dns/rule/#outbound) / + [拨号字段](/zh/configuration/shared/dial/#domain_resolver) / + [路由](/zh/configuration/route/#default_domain_resolver) + +=== ":material-card-remove: 废弃的" + + ```json + { + "dns": { + "servers": [ + { + "address": "local", + "tag": "local" + } + ], + "rules": [ + { + "outbound": "any", + "server": "local" + } + ] + }, + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080 + } + ] + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + } + ] + }, + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080, + "domain_resolver": { + "server": "local", + "rewrite_ttl": 60, + "client_subnet": "1.1.1.1" + }, + // 或 "domain_resolver": "local", + } + ], + + // 或 + + "route": { + "default_domain_resolver": { + "server": "local", + "rewrite_ttl": 60, + "client_subnet": "1.1.1.1" + } + } + } + ``` + +### 迁移出站域名策略选项到域名解析器 + +拨号字段中的 `domain_strategy` 选项已被弃用,可以用新的域名解析器选项替代。 + +请注意,由于 sing-box 1.12 中引入的一些新 DNS 服务器使用了拨号字段,一些人错误地认为 `domain_strategy` 与旧 DNS 服务器中的功能相同。 + +!!! info "参考" + + [拨号字段](/zh/configuration/shared/dial/#domain_strategy) + +=== ":material-card-remove: 弃用的" + + ```json + { + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080, + "domain_strategy": "prefer_ipv4", + } + ] + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + } + ] + }, + "outbounds": [ + { + "type": "socks", + "server": "example.org", + "server_port": 2080, + "domain_resolver": { + "server": "local", + "strategy": "prefer_ipv4" + } + } + ] + } + ``` + +## 1.11.0 + +### 迁移旧的特殊出站到规则动作 + +旧的特殊出站已被弃用,且可以被规则动作替代。 + +!!! info "参考" + + [规则动作](/zh/configuration/route/rule_action/) / + [Block](/zh/configuration/outbound/block/) / + [DNS](/zh/configuration/outbound/dns) + +=== "Block" + + === ":material-card-remove: 弃用的" + + ```json + { + "outbounds": [ + { + "type": "block", + "tag": "block" + } + ], + "route": { + "rules": [ + { + ..., + + "outbound": "block" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "route": { + "rules": [ + { + ..., + + "action": "reject" + } + ] + } + } + ``` + +=== "DNS" + + === ":material-card-remove: 弃用的" + + ```json + { + "inbound": [ + { + ..., + + "sniff": true + } + ], + "outbounds": [ + { + "tag": "dns", + "type": "dns" + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "outbound": "dns" + } + ] + } + } + ``` + + === ":material-card-multiple: 新的" + + ```json + { + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + } + ] + } + } + ``` + +### 迁移旧的入站字段到规则动作 + +入站选项已被弃用,且可以被规则动作替代。 + +!!! info "参考" + + [监听字段](/zh/configuration/shared/listen/) / + [规则](/zh/configuration/route/rule/) / + [规则动作](/zh/configuration/route/rule_action/) / + [DNS 规则](/zh/configuration/dns/rule/) / + [DNS 规则动作](/zh/configuration/dns/rule_action/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "inbounds": [ + { + "type": "mixed", + "sniff": true, + "sniff_timeout": "1s", + "domain_strategy": "prefer_ipv4" + } + ] + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "inbounds": [ + { + "type": "mixed", + "tag": "in" + } + ], + "route": { + "rules": [ + { + "inbound": "in", + "action": "resolve", + "strategy": "prefer_ipv4" + }, + { + "inbound": "in", + "action": "sniff", + "timeout": "1s" + } + ] + } + } + ``` + +### 迁移 direct 出站中的目标地址覆盖字段到路由字段 + +direct 出站中的目标地址覆盖字段已废弃,且可以被路由字段替代。 + +!!! info "参考" + + [Rule Action](/zh/configuration/route/rule_action/) / + [Direct](/zh/configuration/outbound/direct/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "outbounds": [ + { + "type": "direct", + "override_address": "1.1.1.1", + "override_port": 443 + } + ] + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "route": { + "rules": [ + { + "action": "route-options", // 或 route + "override_address": "1.1.1.1", + "override_port": 443 + } + ] + } + } + ``` + +### 迁移 WireGuard 出站到端点 + +WireGuard 出站已被弃用,且可以被端点替代。 + +!!! info "参考" + + [端点](/zh/configuration/endpoint/) / + [WireGuard 端点](/zh/configuration/endpoint/wireguard/) / + [WireGuard 出站](/zh/configuration/outbound/wireguard/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "outbounds": [ + { + "type": "wireguard", + "tag": "wg-out", + + "server": "127.0.0.1", + "server_port": 10001, + "system_interface": true, + "gso": true, + "interface_name": "wg0", + "local_address": [ + "10.0.0.1/32" + ], + "private_key": "", + "peer_public_key": "", + "pre_shared_key": "", + "reserved": [0, 0, 0], + "mtu": 1408 + } + ] + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "endpoints": [ + { + "type": "wireguard", + "tag": "wg-ep", + "system": true, + "name": "wg0", + "mtu": 1408, + "address": [ + "10.0.0.2/32" + ], + "private_key": "", + "listen_port": 10000, + "peers": [ + { + "address": "127.0.0.1", + "port": 10001, + "public_key": "", + "pre_shared_key": "", + "allowed_ips": [ + "0.0.0.0/0" + ], + "persistent_keepalive_interval": 30, + "reserved": [0, 0, 0] + } + ] + } + ] + } + ``` + +## 1.10.0 + +### TUN 地址字段已合并 + +`inet4_address` 和 `inet6_address` 已合并为 `address`, +`inet4_route_address` 和 `inet6_route_address` 已合并为 `route_address`, +`inet4_route_exclude_address` 和 `inet6_route_exclude_address` 已合并为 `route_exclude_address`。 + +!!! info "参考" + + [TUN](/zh/configuration/inbound/tun/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "inbounds": [ + { + "type": "tun", + "inet4_address": "172.19.0.1/30", + "inet6_address": "fdfe:dcba:9876::1/126", + "inet4_route_address": [ + "0.0.0.0/1", + "128.0.0.0/1" + ], + "inet6_route_address": [ + "::/1", + "8000::/1" + ], + "inet4_route_exclude_address": [ + "192.168.0.0/16" + ], + "inet6_route_exclude_address": [ + "fc00::/7" + ] + } + ] + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "inbounds": [ + { + "type": "tun", + "address": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "route_address": [ + "0.0.0.0/1", + "128.0.0.0/1", + "::/1", + "8000::/1" + ], + "route_exclude_address": [ + "192.168.0.0/16", + "fc00::/7" + ] + } + ] + } + ``` + +## 1.9.5 + +### Apple 平台客户端的 Bundle Identifier 更新 + +由于我们旧的苹果开发者账户存在问题,我们只能通过更新 Bundle Identifiers +来重新上架 sing-box 应用, 这意味着数据不会自动继承。 + +对于 iOS,您需要自行备份旧的数据(如果您仍然可以访问); +对于 Apple tvOS,您需要从 iPhone 或 iPad 重新导入配置或者手动创建; +对于 macOS,您可以使用以下命令迁移数据文件夹: + +```bash +cd ~/Library/Group\ Containers && \ + mv group.io.nekohasekai.sfa group.io.nekohasekai.sfavt +``` + +## 1.9.0 + +### `domain_suffix` 行为更新 + +由于历史原因,sing-box 的 `domain_suffix` 规则匹配字面前缀,而不与其他项目相同。 + +sing-box 1.9.0 修改了 `domain_suffix` 的行为:如果规则值以 `.` 为前缀则行为不变,否则改为匹配 `(domain|.+\.domain)`。 + +### 对 Windows 上 `process_path` 格式的更新 + +sing-box 的 `process_path` 规则继承自Clash, +原始代码使用本地系统的路径格式(例如 `\Device\HarddiskVolume1\folder\program.exe`), +但是当设备有多个硬盘时,该 HarddiskVolume 系列号并不稳定。 + +sing-box 1.9.0 使 QueryFullProcessImageNameW 输出 Win32 路径(如 `C:\folder\program.exe`), +这将会破坏现有的 Windows `process_path` 用例。 + +## 1.8.0 + +### :material-close-box: 将缓存文件从 Clash API 迁移到独立选项 + +!!! info "参考" + + [Clash API](/zh/configuration/experimental/clash-api/) / + [Cache File](/zh/configuration/experimental/cache-file/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "experimental": { + "clash_api": { + "cache_file": "cache.db", // 默认值 + "cahce_id": "my_profile2", + "store_mode": true, + "store_selected": true, + "store_fakeip": true + } + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "experimental" : { + "cache_file": { + "enabled": true, + "path": "cache.db", // 默认值 + "cache_id": "my_profile2", + "store_fakeip": true + } + } + } + ``` + +### :material-checkbox-intermediate: 迁移 GeoIP 到规则集 + +!!! info "参考" + + [GeoIP](/zh/configuration/route/geoip/) / + [路由](/zh/configuration/route/) / + [路由规则](/zh/configuration/route/rule/) / + [DNS 规则](/zh/configuration/dns/rule/) / + [规则集](/zh/configuration/rule-set/) + +!!! tip + + `sing-box geoip` 命令可以帮助您将自定义 GeoIP 转换为规则集。 + +=== ":material-card-remove: 弃用的" + + ```json + { + "route": { + "rules": [ + { + "geoip": "private", + "outbound": "direct" + }, + { + "geoip": "cn", + "outbound": "direct" + }, + { + "source_geoip": "cn", + "outbound": "block" + } + ], + "geoip": { + "download_detour": "proxy" + } + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "route": { + "rules": [ + { + "ip_is_private": true, + "outbound": "direct" + }, + { + "rule_set": "geoip-cn", + "outbound": "direct" + }, + { + "rule_set": "geoip-us", + "rule_set_ipcidr_match_source": true, + "outbound": "block" + } + ], + "rule_set": [ + { + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": "proxy" + }, + { + "tag": "geoip-us", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-us.srs", + "download_detour": "proxy" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true // required to save rule-set cache + } + } + } + ``` + +### :material-checkbox-intermediate: 迁移 Geosite 到规则集 + +!!! info "参考" + + [Geosite](/zh/configuration/route/geosite/) / + [路由](/zh/configuration/route/) / + [路由规则](/zh/configuration/route/rule/) / + [DNS 规则](/zh/configuration/dns/rule/) / + [规则集](/zh/configuration/rule-set/) + +!!! tip + + `sing-box geosite` 命令可以帮助您将自定义 Geosite 转换为规则集。 + +=== ":material-card-remove: 弃用的" + + ```json + { + "route": { + "rules": [ + { + "geosite": "cn", + "outbound": "direct" + } + ], + "geosite": { + "download_detour": "proxy" + } + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "route": { + "rules": [ + { + "rule_set": "geosite-cn", + "outbound": "direct" + } + ], + "rule_set": [ + { + "tag": "geosite-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", + "download_detour": "proxy" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true // required to save rule-set cache + } + } + } + ``` diff --git a/docs/sponsors.md b/docs/sponsors.md new file mode 100644 index 00000000..33898928 --- /dev/null +++ b/docs/sponsors.md @@ -0,0 +1,32 @@ +--- +icon: material/hand-coin +--- + +# Sponsors + +Do you or your friends use sing-box? + +You can help keep the project bug-free and feature rich by sponsoring +the project maintainer via [GitHub Sponsors](https://github.com/sponsors/nekohasekai). + +![](https://nekohasekai.github.io/sponsor-images/sponsors.svg) + +## Commercial Sponsors + +> [Warp](https://go.warp.dev/sing-box), Built for coding with multiple AI agents. + +[![](https://github.com/warpdotdev/brand-assets/raw/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png)](https://go.warp.dev/sing-box) + +## Special Sponsors + +> Viral Tech, Inc. + +Helping us re-list sing-box apps on the Apple Store. + +--- + +> [JetBrains](https://www.jetbrains.com) + +Free license for the amazing IDEs. + +[![JetBrains logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://www.jetbrains.com) diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 00000000..8443ffaa --- /dev/null +++ b/docs/support.md @@ -0,0 +1,12 @@ +--- +icon: material/forum +--- + +# Support + +| Channel | Link | +| :---------------------------- | :------------------------------------------ | +| GitHub Issues | https://github.com/SagerNet/sing-box/issues | +| Telegram notification channel | https://t.me/yapnc | +| Telegram user group | https://t.me/yapug | +| Email | contact@sagernet.org | diff --git a/docs/support.zh.md b/docs/support.zh.md new file mode 100644 index 00000000..ff1978ee --- /dev/null +++ b/docs/support.zh.md @@ -0,0 +1,13 @@ +--- +icon: material/forum +--- + +# 支持 + +| 通道 | 链接 | +| :---------------- | :------------------------------------------ | +| GitHub Issues | https://github.com/SagerNet/sing-box/issues | +| Telegram 通知频道 | https://t.me/yapnc | +| Telegram 用户组 | https://t.me/yapug | +| 邮件 | contact@sagernet.org | + diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go new file mode 100644 index 00000000..3198fc6a --- /dev/null +++ b/experimental/cachefile/cache.go @@ -0,0 +1,417 @@ +package cachefile + +import ( + "context" + "errors" + "net/netip" + "os" + "strings" + "sync" + "time" + + "github.com/sagernet/bbolt" + bboltErrors "github.com/sagernet/bbolt/errors" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service/filemanager" +) + +var ( + bucketSelected = []byte("selected") + bucketExpand = []byte("group_expand") + bucketMode = []byte("clash_mode") + bucketRuleSet = []byte("rule_set") + + bucketNameList = []string{ + string(bucketSelected), + string(bucketExpand), + string(bucketMode), + string(bucketRuleSet), + string(bucketRDRC), + string(bucketDNSCache), + } + + cacheIDDefault = []byte("default") +) + +var _ adapter.CacheFile = (*CacheFile)(nil) + +type CacheFile struct { + ctx context.Context + logger logger.Logger + path string + cacheID []byte + storeFakeIP bool + storeRDRC bool + storeDNS bool + disableExpire bool + rdrcTimeout time.Duration + optimisticTimeout time.Duration + DB *bbolt.DB + resetAccess sync.Mutex + saveMetadataTimer *time.Timer + saveFakeIPAccess sync.RWMutex + saveDomain map[netip.Addr]string + saveAddress4 map[string]netip.Addr + saveAddress6 map[string]netip.Addr + saveRDRCAccess sync.RWMutex + saveRDRC map[saveCacheKey]bool + saveDNSCacheAccess sync.RWMutex + saveDNSCache map[saveCacheKey]saveDNSCacheEntry +} + +type saveCacheKey struct { + TransportName string + QuestionName string + QType uint16 +} + +type saveDNSCacheEntry struct { + rawMessage []byte + expireAt time.Time + sequence uint64 + saving bool +} + +func New(ctx context.Context, logger logger.Logger, options option.CacheFileOptions) *CacheFile { + var path string + if options.Path != "" { + path = options.Path + } else { + path = "cache.db" + } + var cacheIDBytes []byte + if options.CacheID != "" { + cacheIDBytes = append([]byte{0}, []byte(options.CacheID)...) + } + if options.StoreRDRC { + deprecated.Report(ctx, deprecated.OptionStoreRDRC) + } + var rdrcTimeout time.Duration + if options.StoreRDRC { + if options.RDRCTimeout > 0 { + rdrcTimeout = time.Duration(options.RDRCTimeout) + } else { + rdrcTimeout = 7 * 24 * time.Hour + } + } + return &CacheFile{ + ctx: ctx, + logger: logger, + path: filemanager.BasePath(ctx, path), + cacheID: cacheIDBytes, + storeFakeIP: options.StoreFakeIP, + storeRDRC: options.StoreRDRC, + storeDNS: options.StoreDNS, + rdrcTimeout: rdrcTimeout, + saveDomain: make(map[netip.Addr]string), + saveAddress4: make(map[string]netip.Addr), + saveAddress6: make(map[string]netip.Addr), + saveRDRC: make(map[saveCacheKey]bool), + saveDNSCache: make(map[saveCacheKey]saveDNSCacheEntry), + } +} + +func (c *CacheFile) Name() string { + return "cache-file" +} + +func (c *CacheFile) Dependencies() []string { + return nil +} + +func (c *CacheFile) SetOptimisticTimeout(timeout time.Duration) { + c.optimisticTimeout = timeout +} + +func (c *CacheFile) SetDisableExpire(disableExpire bool) { + c.disableExpire = disableExpire +} + +func (c *CacheFile) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateInitialize: + return c.start() + case adapter.StartStateStart: + c.startCacheCleanup() + } + return nil +} + +func (c *CacheFile) startCacheCleanup() { + if c.storeDNS { + c.clearRDRC() + c.cleanupDNSCache() + interval := c.optimisticTimeout / 2 + if interval <= 0 { + interval = time.Hour + } + go c.loopCacheCleanup(interval, c.cleanupDNSCache) + } else if c.storeRDRC { + c.cleanupRDRC() + interval := c.rdrcTimeout / 2 + if interval <= 0 { + interval = time.Hour + } + go c.loopCacheCleanup(interval, c.cleanupRDRC) + } +} + +func (c *CacheFile) start() error { + const fileMode = 0o666 + options := bbolt.Options{Timeout: time.Second} + var ( + db *bbolt.DB + err error + ) + for i := 0; i < 10; i++ { + db, err = bbolt.Open(c.path, fileMode, &options) + if err == nil { + break + } + if errors.Is(err, bboltErrors.ErrTimeout) { + continue + } + if E.IsMulti(err, bboltErrors.ErrInvalid, bboltErrors.ErrChecksum, bboltErrors.ErrVersionMismatch) { + rmErr := os.Remove(c.path) + if rmErr != nil { + return err + } + } + time.Sleep(100 * time.Millisecond) + } + if err != nil { + return err + } + err = filemanager.Chown(c.ctx, c.path) + if err != nil { + db.Close() + return E.Cause(err, "platform chown") + } + err = db.Batch(func(tx *bbolt.Tx) error { + return tx.ForEach(func(name []byte, b *bbolt.Bucket) error { + if name[0] == 0 { + return b.ForEachBucket(func(k []byte) error { + bucketName := string(k) + if !(common.Contains(bucketNameList, bucketName)) { + _ = b.DeleteBucket(name) + } + return nil + }) + } else { + bucketName := string(name) + if !(common.Contains(bucketNameList, bucketName) || strings.HasPrefix(bucketName, fakeipBucketPrefix)) { + _ = tx.DeleteBucket(name) + } + } + return nil + }) + }) + if err != nil { + db.Close() + return err + } + c.DB = db + return nil +} + +func (c *CacheFile) Close() error { + if c.DB == nil { + return nil + } + return c.DB.Close() +} + +func (c *CacheFile) view(fn func(tx *bbolt.Tx) error) (err error) { + defer func() { + if r := recover(); r != nil { + c.resetDB() + err = E.New("database corrupted: ", r) + } + }() + return c.DB.View(fn) +} + +func (c *CacheFile) batch(fn func(tx *bbolt.Tx) error) (err error) { + defer func() { + if r := recover(); r != nil { + c.resetDB() + err = E.New("database corrupted: ", r) + } + }() + return c.DB.Batch(fn) +} + +func (c *CacheFile) update(fn func(tx *bbolt.Tx) error) (err error) { + defer func() { + if r := recover(); r != nil { + c.resetDB() + err = E.New("database corrupted: ", r) + } + }() + return c.DB.Update(fn) +} + +func (c *CacheFile) resetDB() { + c.resetAccess.Lock() + defer c.resetAccess.Unlock() + c.DB.Close() + os.Remove(c.path) + db, err := bbolt.Open(c.path, 0o666, &bbolt.Options{Timeout: time.Second}) + if err == nil { + _ = filemanager.Chown(c.ctx, c.path) + c.DB = db + } +} + +func (c *CacheFile) StoreFakeIP() bool { + return c.storeFakeIP +} + +func (c *CacheFile) LoadMode() string { + var mode string + c.view(func(t *bbolt.Tx) error { + bucket := t.Bucket(bucketMode) + if bucket == nil { + return nil + } + var modeBytes []byte + if len(c.cacheID) > 0 { + modeBytes = bucket.Get(c.cacheID) + } else { + modeBytes = bucket.Get(cacheIDDefault) + } + mode = string(modeBytes) + return nil + }) + return mode +} + +func (c *CacheFile) StoreMode(mode string) error { + return c.batch(func(t *bbolt.Tx) error { + bucket, err := t.CreateBucketIfNotExists(bucketMode) + if err != nil { + return err + } + if len(c.cacheID) > 0 { + return bucket.Put(c.cacheID, []byte(mode)) + } else { + return bucket.Put(cacheIDDefault, []byte(mode)) + } + }) +} + +func (c *CacheFile) bucket(t *bbolt.Tx, key []byte) *bbolt.Bucket { + if c.cacheID == nil { + return t.Bucket(key) + } + bucket := t.Bucket(c.cacheID) + if bucket == nil { + return nil + } + return bucket.Bucket(key) +} + +func (c *CacheFile) createBucket(t *bbolt.Tx, key []byte) (*bbolt.Bucket, error) { + if c.cacheID == nil { + return t.CreateBucketIfNotExists(key) + } + bucket, err := t.CreateBucketIfNotExists(c.cacheID) + if bucket == nil { + return nil, err + } + return bucket.CreateBucketIfNotExists(key) +} + +func (c *CacheFile) LoadSelected(group string) string { + var selected string + c.view(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketSelected) + if bucket == nil { + return nil + } + selectedBytes := bucket.Get([]byte(group)) + if len(selectedBytes) > 0 { + selected = string(selectedBytes) + } + return nil + }) + return selected +} + +func (c *CacheFile) StoreSelected(group, selected string) error { + return c.batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketSelected) + if err != nil { + return err + } + return bucket.Put([]byte(group), []byte(selected)) + }) +} + +func (c *CacheFile) LoadGroupExpand(group string) (isExpand bool, loaded bool) { + c.view(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketExpand) + if bucket == nil { + return nil + } + expandBytes := bucket.Get([]byte(group)) + if len(expandBytes) == 1 { + isExpand = expandBytes[0] == 1 + loaded = true + } + return nil + }) + return +} + +func (c *CacheFile) StoreGroupExpand(group string, isExpand bool) error { + return c.batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketExpand) + if err != nil { + return err + } + if isExpand { + return bucket.Put([]byte(group), []byte{1}) + } else { + return bucket.Put([]byte(group), []byte{0}) + } + }) +} + +func (c *CacheFile) LoadRuleSet(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.view(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedBinary) error { + return c.batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + setBinary, err := set.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/cachefile/dns_cache.go b/experimental/cachefile/dns_cache.go new file mode 100644 index 00000000..914c7e5a --- /dev/null +++ b/experimental/cachefile/dns_cache.go @@ -0,0 +1,299 @@ +package cachefile + +import ( + "encoding/binary" + "time" + + "github.com/sagernet/bbolt" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/logger" +) + +var bucketDNSCache = []byte("dns_cache") + +func (c *CacheFile) StoreDNS() bool { + return c.storeDNS +} + +func (c *CacheFile) LoadDNSCache(transportName string, qName string, qType uint16) (rawMessage []byte, expireAt time.Time, loaded bool) { + c.saveDNSCacheAccess.RLock() + entry, cached := c.saveDNSCache[saveCacheKey{transportName, qName, qType}] + c.saveDNSCacheAccess.RUnlock() + if cached { + return entry.rawMessage, entry.expireAt, true + } + key := buf.Get(2 + len(qName)) + binary.BigEndian.PutUint16(key, qType) + copy(key[2:], qName) + defer buf.Put(key) + err := c.view(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketDNSCache) + if bucket == nil { + return nil + } + bucket = bucket.Bucket([]byte(transportName)) + if bucket == nil { + return nil + } + content := bucket.Get(key) + if len(content) < 8 { + return nil + } + expireAt = time.Unix(int64(binary.BigEndian.Uint64(content[:8])), 0) + rawMessage = make([]byte, len(content)-8) + copy(rawMessage, content[8:]) + loaded = true + return nil + }) + if err != nil { + return nil, time.Time{}, false + } + return +} + +func (c *CacheFile) SaveDNSCache(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time) error { + return c.batch(func(tx *bbolt.Tx) error { + bucket, err := c.createBucket(tx, bucketDNSCache) + if err != nil { + return err + } + bucket, err = bucket.CreateBucketIfNotExists([]byte(transportName)) + if err != nil { + return err + } + key := buf.Get(2 + len(qName)) + binary.BigEndian.PutUint16(key, qType) + copy(key[2:], qName) + defer buf.Put(key) + value := buf.Get(8 + len(rawMessage)) + defer buf.Put(value) + binary.BigEndian.PutUint64(value[:8], uint64(expireAt.Unix())) + copy(value[8:], rawMessage) + return bucket.Put(key, value) + }) +} + +func (c *CacheFile) SaveDNSCacheAsync(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time, logger logger.Logger) { + saveKey := saveCacheKey{transportName, qName, qType} + if !c.queueDNSCacheSave(saveKey, rawMessage, expireAt) { + return + } + go c.flushPendingDNSCache(saveKey, logger) +} + +func (c *CacheFile) queueDNSCacheSave(saveKey saveCacheKey, rawMessage []byte, expireAt time.Time) bool { + c.saveDNSCacheAccess.Lock() + defer c.saveDNSCacheAccess.Unlock() + entry := c.saveDNSCache[saveKey] + entry.rawMessage = append([]byte(nil), rawMessage...) + entry.expireAt = expireAt + entry.sequence++ + startFlush := !entry.saving + entry.saving = true + c.saveDNSCache[saveKey] = entry + return startFlush +} + +func (c *CacheFile) flushPendingDNSCache(saveKey saveCacheKey, logger logger.Logger) { + c.flushPendingDNSCacheWith(saveKey, logger, func(entry saveDNSCacheEntry) error { + return c.SaveDNSCache(saveKey.TransportName, saveKey.QuestionName, saveKey.QType, entry.rawMessage, entry.expireAt) + }) +} + +func (c *CacheFile) flushPendingDNSCacheWith(saveKey saveCacheKey, logger logger.Logger, save func(saveDNSCacheEntry) error) { + for { + c.saveDNSCacheAccess.RLock() + entry, loaded := c.saveDNSCache[saveKey] + c.saveDNSCacheAccess.RUnlock() + if !loaded { + return + } + err := save(entry) + if err != nil { + logger.Warn("save DNS cache: ", err) + } + c.saveDNSCacheAccess.Lock() + currentEntry, loaded := c.saveDNSCache[saveKey] + if !loaded { + c.saveDNSCacheAccess.Unlock() + return + } + if currentEntry.sequence != entry.sequence { + c.saveDNSCacheAccess.Unlock() + continue + } + delete(c.saveDNSCache, saveKey) + c.saveDNSCacheAccess.Unlock() + return + } +} + +func (c *CacheFile) ClearDNSCache() error { + c.saveDNSCacheAccess.Lock() + clear(c.saveDNSCache) + c.saveDNSCacheAccess.Unlock() + return c.batch(func(tx *bbolt.Tx) error { + if c.cacheID == nil { + bucket := tx.Bucket(bucketDNSCache) + if bucket == nil { + return nil + } + return tx.DeleteBucket(bucketDNSCache) + } + bucket := tx.Bucket(c.cacheID) + if bucket == nil || bucket.Bucket(bucketDNSCache) == nil { + return nil + } + return bucket.DeleteBucket(bucketDNSCache) + }) +} + +func (c *CacheFile) loopCacheCleanup(interval time.Duration, cleanupFunc func()) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + cleanupFunc() + } + } +} + +func (c *CacheFile) cleanupDNSCache() { + now := time.Now() + err := c.batch(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketDNSCache) + if bucket == nil { + return nil + } + var emptyTransports [][]byte + err := bucket.ForEachBucket(func(transportName []byte) error { + transportBucket := bucket.Bucket(transportName) + if transportBucket == nil { + return nil + } + var expiredKeys [][]byte + err := transportBucket.ForEach(func(key, value []byte) error { + if len(value) < 8 { + expiredKeys = append(expiredKeys, append([]byte(nil), key...)) + return nil + } + if c.disableExpire { + return nil + } + expireAt := time.Unix(int64(binary.BigEndian.Uint64(value[:8])), 0) + if now.After(expireAt.Add(c.optimisticTimeout)) { + expiredKeys = append(expiredKeys, append([]byte(nil), key...)) + } + return nil + }) + if err != nil { + return err + } + for _, key := range expiredKeys { + err = transportBucket.Delete(key) + if err != nil { + return err + } + } + first, _ := transportBucket.Cursor().First() + if first == nil { + emptyTransports = append(emptyTransports, append([]byte(nil), transportName...)) + } + return nil + }) + if err != nil { + return err + } + for _, name := range emptyTransports { + err = bucket.DeleteBucket(name) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + c.logger.Warn("cleanup DNS cache: ", err) + } +} + +func (c *CacheFile) clearRDRC() { + c.saveRDRCAccess.Lock() + clear(c.saveRDRC) + c.saveRDRCAccess.Unlock() + err := c.batch(func(tx *bbolt.Tx) error { + if c.cacheID == nil { + if tx.Bucket(bucketRDRC) == nil { + return nil + } + return tx.DeleteBucket(bucketRDRC) + } + bucket := tx.Bucket(c.cacheID) + if bucket == nil || bucket.Bucket(bucketRDRC) == nil { + return nil + } + return bucket.DeleteBucket(bucketRDRC) + }) + if err != nil { + c.logger.Warn("clear RDRC: ", err) + } +} + +func (c *CacheFile) cleanupRDRC() { + now := time.Now() + err := c.batch(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketRDRC) + if bucket == nil { + return nil + } + var emptyTransports [][]byte + err := bucket.ForEachBucket(func(transportName []byte) error { + transportBucket := bucket.Bucket(transportName) + if transportBucket == nil { + return nil + } + var expiredKeys [][]byte + err := transportBucket.ForEach(func(key, value []byte) error { + if len(value) < 8 { + expiredKeys = append(expiredKeys, append([]byte(nil), key...)) + return nil + } + expiresAt := time.Unix(int64(binary.BigEndian.Uint64(value)), 0) + if now.After(expiresAt) { + expiredKeys = append(expiredKeys, append([]byte(nil), key...)) + } + return nil + }) + if err != nil { + return err + } + for _, key := range expiredKeys { + err = transportBucket.Delete(key) + if err != nil { + return err + } + } + first, _ := transportBucket.Cursor().First() + if first == nil { + emptyTransports = append(emptyTransports, append([]byte(nil), transportName...)) + } + return nil + }) + if err != nil { + return err + } + for _, name := range emptyTransports { + err = bucket.DeleteBucket(name) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + c.logger.Warn("cleanup RDRC: ", err) + } +} diff --git a/experimental/cachefile/fakeip.go b/experimental/cachefile/fakeip.go new file mode 100644 index 00000000..7a4bd384 --- /dev/null +++ b/experimental/cachefile/fakeip.go @@ -0,0 +1,194 @@ +package cachefile + +import ( + "net/netip" + "os" + "time" + + "github.com/sagernet/bbolt" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" +) + +const fakeipBucketPrefix = "fakeip_" + +var ( + bucketFakeIP = []byte(fakeipBucketPrefix + "address") + bucketFakeIPDomain4 = []byte(fakeipBucketPrefix + "domain4") + bucketFakeIPDomain6 = []byte(fakeipBucketPrefix + "domain6") + keyMetadata = []byte(fakeipBucketPrefix + "metadata") +) + +func (c *CacheFile) FakeIPMetadata() *adapter.FakeIPMetadata { + var metadata adapter.FakeIPMetadata + err := c.batch(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(bucketFakeIP) + if bucket == nil { + return os.ErrNotExist + } + metadataBinary := bucket.Get(keyMetadata) + if len(metadataBinary) == 0 { + return os.ErrInvalid + } + err := bucket.Delete(keyMetadata) + if err != nil { + return err + } + return metadata.UnmarshalBinary(metadataBinary) + }) + if err != nil { + return nil + } + return &metadata +} + +func (c *CacheFile) FakeIPSaveMetadata(metadata *adapter.FakeIPMetadata) error { + return c.batch(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists(bucketFakeIP) + if err != nil { + return err + } + metadataBinary, err := metadata.MarshalBinary() + if err != nil { + return err + } + return bucket.Put(keyMetadata, metadataBinary) + }) +} + +func (c *CacheFile) FakeIPSaveMetadataAsync(metadata *adapter.FakeIPMetadata) { + if c.saveMetadataTimer == nil { + c.saveMetadataTimer = time.AfterFunc(C.FakeIPMetadataSaveInterval, func() { + _ = c.FakeIPSaveMetadata(metadata) + }) + } else { + c.saveMetadataTimer.Reset(C.FakeIPMetadataSaveInterval) + } +} + +func (c *CacheFile) FakeIPStore(address netip.Addr, domain string) error { + return c.batch(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists(bucketFakeIP) + if err != nil { + return err + } + oldDomain := bucket.Get(address.AsSlice()) + err = bucket.Put(address.AsSlice(), []byte(domain)) + if err != nil { + return err + } + if address.Is4() { + bucket, err = tx.CreateBucketIfNotExists(bucketFakeIPDomain4) + } else { + bucket, err = tx.CreateBucketIfNotExists(bucketFakeIPDomain6) + } + if err != nil { + return err + } + if oldDomain != nil { + if err := bucket.Delete(oldDomain); err != nil { + return err + } + } + return bucket.Put([]byte(domain), address.AsSlice()) + }) +} + +func (c *CacheFile) FakeIPStoreAsync(address netip.Addr, domain string, logger logger.Logger) { + c.saveFakeIPAccess.Lock() + if oldDomain, loaded := c.saveDomain[address]; loaded { + if address.Is4() { + delete(c.saveAddress4, oldDomain) + } else { + delete(c.saveAddress6, oldDomain) + } + } + c.saveDomain[address] = domain + if address.Is4() { + c.saveAddress4[domain] = address + } else { + c.saveAddress6[domain] = address + } + c.saveFakeIPAccess.Unlock() + go func() { + err := c.FakeIPStore(address, domain) + if err != nil { + logger.Warn("save FakeIP cache: ", err) + } + c.saveFakeIPAccess.Lock() + delete(c.saveDomain, address) + if address.Is4() { + delete(c.saveAddress4, domain) + } else { + delete(c.saveAddress6, domain) + } + c.saveFakeIPAccess.Unlock() + }() +} + +func (c *CacheFile) FakeIPLoad(address netip.Addr) (string, bool) { + c.saveFakeIPAccess.RLock() + cachedDomain, cached := c.saveDomain[address] + c.saveFakeIPAccess.RUnlock() + if cached { + return cachedDomain, true + } + var domain string + _ = c.view(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(bucketFakeIP) + if bucket == nil { + return nil + } + domain = string(bucket.Get(address.AsSlice())) + return nil + }) + return domain, domain != "" +} + +func (c *CacheFile) FakeIPLoadDomain(domain string, isIPv6 bool) (netip.Addr, bool) { + var ( + cachedAddress netip.Addr + cached bool + ) + c.saveFakeIPAccess.RLock() + if !isIPv6 { + cachedAddress, cached = c.saveAddress4[domain] + } else { + cachedAddress, cached = c.saveAddress6[domain] + } + c.saveFakeIPAccess.RUnlock() + if cached { + return cachedAddress, true + } + var address netip.Addr + _ = c.view(func(tx *bbolt.Tx) error { + var bucket *bbolt.Bucket + if isIPv6 { + bucket = tx.Bucket(bucketFakeIPDomain6) + } else { + bucket = tx.Bucket(bucketFakeIPDomain4) + } + if bucket == nil { + return nil + } + address = M.AddrFromIP(bucket.Get([]byte(domain))) + return nil + }) + return address, address.IsValid() +} + +func (c *CacheFile) FakeIPReset() error { + return c.batch(func(tx *bbolt.Tx) error { + err := tx.DeleteBucket(bucketFakeIP) + if err != nil { + return err + } + err = tx.DeleteBucket(bucketFakeIPDomain4) + if err != nil { + return err + } + return tx.DeleteBucket(bucketFakeIPDomain6) + }) +} diff --git a/experimental/cachefile/rdrc.go b/experimental/cachefile/rdrc.go new file mode 100644 index 00000000..26056f57 --- /dev/null +++ b/experimental/cachefile/rdrc.go @@ -0,0 +1,109 @@ +package cachefile + +import ( + "encoding/binary" + "time" + + "github.com/sagernet/bbolt" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/logger" +) + +var bucketRDRC = []byte("rdrc2") + +func (c *CacheFile) StoreRDRC() bool { + return c.storeRDRC +} + +func (c *CacheFile) RDRCTimeout() time.Duration { + return c.rdrcTimeout +} + +func (c *CacheFile) LoadRDRC(transportName string, qName string, qType uint16) (rejected bool) { + c.saveRDRCAccess.RLock() + rejected, cached := c.saveRDRC[saveCacheKey{transportName, qName, qType}] + c.saveRDRCAccess.RUnlock() + if cached { + return + } + key := buf.Get(2 + len(qName)) + binary.BigEndian.PutUint16(key, qType) + copy(key[2:], qName) + defer buf.Put(key) + var deleteCache bool + err := c.view(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketRDRC) + if bucket == nil { + return nil + } + bucket = bucket.Bucket([]byte(transportName)) + if bucket == nil { + return nil + } + content := bucket.Get(key) + if content == nil { + return nil + } + expiresAt := time.Unix(int64(binary.BigEndian.Uint64(content)), 0) + if time.Now().After(expiresAt) { + deleteCache = true + return nil + } + rejected = true + return nil + }) + if err != nil { + return + } + if deleteCache { + c.update(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketRDRC) + if bucket == nil { + return nil + } + bucket = bucket.Bucket([]byte(transportName)) + if bucket == nil { + return nil + } + return bucket.Delete(key) + }) + } + return +} + +func (c *CacheFile) SaveRDRC(transportName string, qName string, qType uint16) error { + return c.batch(func(tx *bbolt.Tx) error { + bucket, err := c.createBucket(tx, bucketRDRC) + if err != nil { + return err + } + bucket, err = bucket.CreateBucketIfNotExists([]byte(transportName)) + if err != nil { + return err + } + key := buf.Get(2 + len(qName)) + binary.BigEndian.PutUint16(key, qType) + copy(key[2:], qName) + defer buf.Put(key) + expiresAt := buf.Get(8) + defer buf.Put(expiresAt) + binary.BigEndian.PutUint64(expiresAt, uint64(time.Now().Add(c.rdrcTimeout).Unix())) + return bucket.Put(key, expiresAt) + }) +} + +func (c *CacheFile) SaveRDRCAsync(transportName string, qName string, qType uint16, logger logger.Logger) { + saveKey := saveCacheKey{transportName, qName, qType} + c.saveRDRCAccess.Lock() + c.saveRDRC[saveKey] = true + c.saveRDRCAccess.Unlock() + go func() { + err := c.SaveRDRC(transportName, qName, qType) + if err != nil { + logger.Warn("save RDRC: ", err) + } + c.saveRDRCAccess.Lock() + delete(c.saveRDRC, saveKey) + c.saveRDRCAccess.Unlock() + }() +} diff --git a/experimental/clashapi.go b/experimental/clashapi.go new file mode 100644 index 00000000..4ad07c8b --- /dev/null +++ b/experimental/clashapi.go @@ -0,0 +1,81 @@ +package experimental + +import ( + "context" + "os" + "sort" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" +) + +type ClashServerConstructor = func(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) + +var clashServerConstructor ClashServerConstructor + +func RegisterClashServerConstructor(constructor ClashServerConstructor) { + clashServerConstructor = constructor +} + +func NewClashServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { + if clashServerConstructor == nil { + return nil, os.ErrInvalid + } + return clashServerConstructor(ctx, logFactory, options) +} + +func CalculateClashModeList(options option.Options) []string { + var clashModes []string + clashModes = append(clashModes, extraClashModeFromRule(common.PtrValueOrDefault(options.Route).Rules)...) + clashModes = append(clashModes, extraClashModeFromDNSRule(common.PtrValueOrDefault(options.DNS).Rules)...) + clashModes = common.FilterNotDefault(common.Uniq(clashModes)) + predefinedOrder := []string{ + "Rule", "Global", "Direct", + } + var newClashModes []string + for _, mode := range clashModes { + if !common.Contains(predefinedOrder, mode) { + newClashModes = append(newClashModes, mode) + } + } + sort.Strings(newClashModes) + for _, mode := range predefinedOrder { + if common.Contains(clashModes, mode) { + newClashModes = append(newClashModes, mode) + } + } + return newClashModes +} + +func extraClashModeFromRule(rules []option.Rule) []string { + var clashMode []string + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if rule.DefaultOptions.ClashMode != "" { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + } + case C.RuleTypeLogical: + clashMode = append(clashMode, extraClashModeFromRule(rule.LogicalOptions.Rules)...) + } + } + return clashMode +} + +func extraClashModeFromDNSRule(rules []option.DNSRule) []string { + var clashMode []string + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if rule.DefaultOptions.ClashMode != "" { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + } + case C.RuleTypeLogical: + clashMode = append(clashMode, extraClashModeFromDNSRule(rule.LogicalOptions.Rules)...) + } + } + return clashMode +} diff --git a/experimental/clashapi/api_meta.go b/experimental/clashapi/api_meta.go new file mode 100644 index 00000000..8b31d8a4 --- /dev/null +++ b/experimental/clashapi/api_meta.go @@ -0,0 +1,96 @@ +package clashapi + +import ( + "bytes" + "context" + "net" + "net/http" + "runtime/debug" + "time" + + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/ws" + "github.com/sagernet/ws/wsutil" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" +) + +// API created by Clash.Meta + +func (s *Server) setupMetaAPI(r chi.Router) { + if s.logDebug { + r := chi.NewRouter() + r.Put("/gc", func(w http.ResponseWriter, r *http.Request) { + debug.FreeOSMemory() + }) + r.Mount("/", middleware.Profiler()) + } + r.Get("/memory", memory(s.ctx, s.trafficManager)) + r.Mount("/group", groupRouter(s)) + r.Mount("/upgrade", upgradeRouter(s)) +} + +type Memory struct { + Inuse uint64 `json:"inuse"` + OSLimit uint64 `json:"oslimit"` // maybe we need it in the future +} + +func memory(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var conn net.Conn + if r.Header.Get("Upgrade") == "websocket" { + var err error + conn, _, _, err = ws.UpgradeHTTP(r, w) + if err != nil { + return + } + defer conn.Close() + } + + if conn == nil { + w.Header().Set("Content-Type", "application/json") + render.Status(r, http.StatusOK) + } + + tick := time.NewTicker(time.Second) + defer tick.Stop() + buf := &bytes.Buffer{} + var err error + first := true + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + buf.Reset() + + inuse := trafficManager.Snapshot().Memory + + // make chat.js begin with zero + // this is shit var,but we need output 0 for first time + if first { + first = false + inuse = 0 + } + if err := json.NewEncoder(buf).Encode(Memory{ + Inuse: inuse, + OSLimit: 0, + }); err != nil { + break + } + if conn == nil { + _, err = w.Write(buf.Bytes()) + w.(http.Flusher).Flush() + } else { + err = wsutil.WriteServerText(conn, buf.Bytes()) + } + if err != nil { + break + } + } + } +} diff --git a/experimental/clashapi/api_meta_group.go b/experimental/clashapi/api_meta_group.go new file mode 100644 index 00000000..31dbdaf6 --- /dev/null +++ b/experimental/clashapi/api_meta_group.go @@ -0,0 +1,136 @@ +package clashapi + +import ( + "context" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/batch" + "github.com/sagernet/sing/common/json/badjson" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func groupRouter(server *Server) http.Handler { + r := chi.NewRouter() + r.Get("/", getGroups(server)) + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProxyName, findProxyByName(server)) + r.Get("/", getGroup(server)) + r.Get("/delay", getGroupDelay(server)) + }) + return r +} + +func getGroups(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + groups := common.Map(common.Filter(server.outbound.Outbounds(), func(it adapter.Outbound) bool { + _, isGroup := it.(adapter.OutboundGroup) + return isGroup + }), func(it adapter.Outbound) *badjson.JSONObject { + return proxyInfo(server, it) + }) + render.JSON(w, r, render.M{ + "proxies": groups, + }) + } +} + +func getGroup(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + if _, ok := proxy.(adapter.OutboundGroup); ok { + render.JSON(w, r, proxyInfo(server, proxy)) + return + } + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + } +} + +func getGroupDelay(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + outboundGroup, ok := proxy.(adapter.OutboundGroup) + if !ok { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } + + query := r.URL.Query() + url := query.Get("url") + if strings.HasPrefix(url, "http://") { + url = "" + } + timeout, err := strconv.ParseInt(query.Get("timeout"), 10, 32) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), time.Millisecond*time.Duration(timeout)) + defer cancel() + + var result map[string]uint16 + if urlTestGroup, isURLTestGroup := outboundGroup.(adapter.URLTestGroup); isURLTestGroup { + result, err = urlTestGroup.URLTest(ctx) + } else { + outbounds := common.FilterNotNil(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { + itOutbound, _ := server.outbound.Outbound(it) + return itOutbound + })) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + checked := make(map[string]bool) + result = make(map[string]uint16) + var resultAccess sync.Mutex + for _, detour := range outbounds { + tag := detour.Tag() + realTag := group.RealTag(detour) + if checked[realTag] { + continue + } + checked[realTag] = true + p, loaded := server.outbound.Outbound(realTag) + if !loaded { + continue + } + b.Go(realTag, func() (any, error) { + t, err := urltest.URLTest(ctx, url, p) + if err != nil { + server.logger.Debug("outbound ", tag, " unavailable: ", err) + server.urlTestHistory.DeleteURLTestHistory(realTag) + } else { + server.logger.Debug("outbound ", tag, " available: ", t, "ms") + server.urlTestHistory.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + } + + if err != nil { + render.Status(r, http.StatusGatewayTimeout) + render.JSON(w, r, newError(err.Error())) + return + } + + render.JSON(w, r, result) + } +} diff --git a/experimental/clashapi/api_meta_upgrade.go b/experimental/clashapi/api_meta_upgrade.go new file mode 100644 index 00000000..df70088e --- /dev/null +++ b/experimental/clashapi/api_meta_upgrade.go @@ -0,0 +1,36 @@ +package clashapi + +import ( + "net/http" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func upgradeRouter(server *Server) http.Handler { + r := chi.NewRouter() + r.Post("/ui", updateExternalUI(server)) + return r +} + +func updateExternalUI(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if server.externalUI == "" { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, newError("external UI not enabled")) + return + } + server.logger.Info("upgrading external UI") + err := server.downloadExternalUI() + if err != nil { + server.logger.Error(E.Cause(err, "upgrade external ui")) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + server.logger.Info("updated external UI") + render.JSON(w, r, render.M{"status": "ok"}) + } +} diff --git a/experimental/clashapi/cache.go b/experimental/clashapi/cache.go new file mode 100644 index 00000000..4df1f890 --- /dev/null +++ b/experimental/clashapi/cache.go @@ -0,0 +1,44 @@ +package clashapi + +import ( + "context" + "net/http" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func cacheRouter(ctx context.Context) http.Handler { + r := chi.NewRouter() + r.Post("/fakeip/flush", flushFakeip(ctx)) + r.Post("/dns/flush", flushDNS(ctx)) + return r +} + +func flushFakeip(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + cacheFile := service.FromContext[adapter.CacheFile](ctx) + if cacheFile != nil { + err := cacheFile.FakeIPReset() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + } + render.NoContent(w, r) + } +} + +func flushDNS(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + dnsRouter := service.FromContext[adapter.DNSRouter](ctx) + if dnsRouter != nil { + dnsRouter.ClearCache() + } + render.NoContent(w, r) + } +} diff --git a/experimental/clashapi/common.go b/experimental/clashapi/common.go new file mode 100644 index 00000000..416289c0 --- /dev/null +++ b/experimental/clashapi/common.go @@ -0,0 +1,17 @@ +package clashapi + +import ( + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" +) + +// When name is composed of a partial escape string, Golang does not unescape it +func getEscapeParam(r *http.Request, paramName string) string { + param := chi.URLParam(r, paramName) + if newParam, err := url.PathUnescape(param); err == nil { + param = newParam + } + return param +} diff --git a/experimental/clashapi/configs.go b/experimental/clashapi/configs.go new file mode 100644 index 00000000..8ae1d258 --- /dev/null +++ b/experimental/clashapi/configs.go @@ -0,0 +1,71 @@ +package clashapi + +import ( + "net/http" + + "github.com/sagernet/sing-box/log" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func configRouter(server *Server, logFactory log.Factory) http.Handler { + r := chi.NewRouter() + r.Get("/", getConfigs(server, logFactory)) + r.Put("/", updateConfigs) + r.Patch("/", patchConfigs(server)) + return r +} + +type configSchema struct { + Port int `json:"port"` + SocksPort int `json:"socks-port"` + RedirPort int `json:"redir-port"` + TProxyPort int `json:"tproxy-port"` + MixedPort int `json:"mixed-port"` + AllowLan bool `json:"allow-lan"` + BindAddress string `json:"bind-address"` + Mode string `json:"mode"` + // sing-box added + ModeList []string `json:"mode-list"` + LogLevel string `json:"log-level"` + IPv6 bool `json:"ipv6"` + Tun map[string]any `json:"tun"` +} + +func getConfigs(server *Server, logFactory log.Factory) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + logLevel := logFactory.Level() + if logLevel == log.LevelTrace { + logLevel = log.LevelDebug + } else if logLevel < log.LevelError { + logLevel = log.LevelError + } + render.JSON(w, r, &configSchema{ + Mode: server.mode, + ModeList: server.modeList, + BindAddress: "*", + LogLevel: log.FormatLevel(logLevel), + }) + } +} + +func patchConfigs(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var newConfig configSchema + err := render.DecodeJSON(r.Body, &newConfig) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + if newConfig.Mode != "" { + server.SetMode(newConfig.Mode) + } + render.NoContent(w, r) + } +} + +func updateConfigs(w http.ResponseWriter, r *http.Request) { + render.NoContent(w, r) +} diff --git a/experimental/clashapi/connections.go b/experimental/clashapi/connections.go new file mode 100644 index 00000000..14274b31 --- /dev/null +++ b/experimental/clashapi/connections.go @@ -0,0 +1,108 @@ +package clashapi + +import ( + "bytes" + "context" + "net/http" + "strconv" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/ws" + "github.com/sagernet/ws/wsutil" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/gofrs/uuid/v5" +) + +func connectionRouter(ctx context.Context, router adapter.Router, trafficManager *trafficontrol.Manager) http.Handler { + r := chi.NewRouter() + r.Get("/", getConnections(ctx, trafficManager)) + r.Delete("/", closeAllConnections(router, trafficManager)) + r.Delete("/{id}", closeConnection(trafficManager)) + return r +} + +func getConnections(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Upgrade") != "websocket" { + snapshot := trafficManager.Snapshot() + render.JSON(w, r, snapshot) + return + } + + conn, _, _, err := ws.UpgradeHTTP(r, w) + if err != nil { + return + } + defer conn.Close() + + intervalStr := r.URL.Query().Get("interval") + interval := 1000 + if intervalStr != "" { + t, err := strconv.Atoi(intervalStr) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + interval = t + } + + buf := &bytes.Buffer{} + sendSnapshot := func() error { + buf.Reset() + snapshot := trafficManager.Snapshot() + if err := json.NewEncoder(buf).Encode(snapshot); err != nil { + return err + } + return wsutil.WriteServerText(conn, buf.Bytes()) + } + + if err = sendSnapshot(); err != nil { + return + } + + tick := time.NewTicker(time.Millisecond * time.Duration(interval)) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + if err = sendSnapshot(); err != nil { + break + } + } + } +} + +func closeConnection(trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + id := uuid.FromStringOrNil(chi.URLParam(r, "id")) + snapshot := trafficManager.Snapshot() + for _, c := range snapshot.Connections { + if id == c.Metadata().ID { + c.Close() + break + } + } + render.NoContent(w, r) + } +} + +func closeAllConnections(router adapter.Router, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + snapshot := trafficManager.Snapshot() + for _, c := range snapshot.Connections { + c.Close() + } + router.ResetNetwork() + render.NoContent(w, r) + } +} diff --git a/experimental/clashapi/ctxkeys.go b/experimental/clashapi/ctxkeys.go new file mode 100644 index 00000000..3a888026 --- /dev/null +++ b/experimental/clashapi/ctxkeys.go @@ -0,0 +1,14 @@ +package clashapi + +var ( + CtxKeyProxyName = contextKey("proxy name") + CtxKeyProviderName = contextKey("provider name") + CtxKeyProxy = contextKey("proxy") + CtxKeyProvider = contextKey("provider") +) + +type contextKey string + +func (c contextKey) String() string { + return "clash context key " + string(c) +} diff --git a/experimental/clashapi/dns.go b/experimental/clashapi/dns.go new file mode 100644 index 00000000..4f850f82 --- /dev/null +++ b/experimental/clashapi/dns.go @@ -0,0 +1,82 @@ +package clashapi + +import ( + "context" + "net/http" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/miekg/dns" +) + +func dnsRouter(router adapter.DNSRouter) http.Handler { + r := chi.NewRouter() + r.Get("/query", queryDNS(router)) + return r +} + +func queryDNS(router adapter.DNSRouter) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + qTypeStr := r.URL.Query().Get("type") + if qTypeStr == "" { + qTypeStr = "A" + } + + qType, exist := dns.StringToType[qTypeStr] + if !exist { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("invalid query type")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), C.DNSTimeout) + defer cancel() + + msg := dns.Msg{} + msg.SetQuestion(dns.Fqdn(name), qType) + resp, err := router.Exchange(ctx, &msg, adapter.DNSQueryOptions{}) + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + + responseData := render.M{ + "Status": resp.Rcode, + "Question": resp.Question, + "Server": "internal", + "TC": resp.Truncated, + "RD": resp.RecursionDesired, + "RA": resp.RecursionAvailable, + "AD": resp.AuthenticatedData, + "CD": resp.CheckingDisabled, + } + + rr2Json := func(rr dns.RR) render.M { + header := rr.Header() + return render.M{ + "name": header.Name, + "type": header.Rrtype, + "TTL": header.Ttl, + "data": rr.String()[len(header.String()):], + } + } + + if len(resp.Answer) > 0 { + responseData["Answer"] = common.Map(resp.Answer, rr2Json) + } + if len(resp.Ns) > 0 { + responseData["Authority"] = common.Map(resp.Ns, rr2Json) + } + if len(resp.Extra) > 0 { + responseData["Additional"] = common.Map(resp.Extra, rr2Json) + } + + render.JSON(w, r, responseData) + } +} diff --git a/experimental/clashapi/errors.go b/experimental/clashapi/errors.go new file mode 100644 index 00000000..7aaf76b7 --- /dev/null +++ b/experimental/clashapi/errors.go @@ -0,0 +1,22 @@ +package clashapi + +var ( + ErrUnauthorized = newError("Unauthorized") + ErrBadRequest = newError("Body invalid") + ErrForbidden = newError("Forbidden") + ErrNotFound = newError("Resource not found") + ErrRequestTimeout = newError("Timeout") +) + +// HTTPError is custom HTTP error for API +type HTTPError struct { + Message string `json:"message"` +} + +func (e *HTTPError) Error() string { + return e.Message +} + +func newError(msg string) *HTTPError { + return &HTTPError{Message: msg} +} diff --git a/experimental/clashapi/profile.go b/experimental/clashapi/profile.go new file mode 100644 index 00000000..4e20754f --- /dev/null +++ b/experimental/clashapi/profile.go @@ -0,0 +1,53 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func profileRouter() http.Handler { + r := chi.NewRouter() + r.Get("/tracing", subscribeTracing) + return r +} + +func subscribeTracing(w http.ResponseWriter, r *http.Request) { + // if !profile.Tracing.Load() { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + //return + //} + + /*wsConn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + ch := make(chan map[string]any, 1024) + sub := event.Subscribe() + defer event.UnSubscribe(sub) + buf := &bytes.Buffer{} + + go func() { + for elm := range sub { + select { + case ch <- elm: + default: + } + } + close(ch) + }() + + for elm := range ch { + buf.Reset() + if err := json.NewEncoder(buf).Encode(elm); err != nil { + break + } + + if err := wsConn.WriteMessage(websocket.TextMessage, buf.Bytes()); err != nil { + break + } + }*/ +} diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go new file mode 100644 index 00000000..352b2894 --- /dev/null +++ b/experimental/clashapi/provider.go @@ -0,0 +1,74 @@ +package clashapi + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func proxyProviderRouter() http.Handler { + r := chi.NewRouter() + r.Get("/", getProviders) + + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProviderName, findProviderByName) + r.Get("/", getProvider) + r.Put("/", updateProvider) + r.Get("/healthcheck", healthCheckProvider) + }) + return r +} + +func getProviders(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{ + "providers": render.M{}, + }) +} + +func getProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) + render.JSON(w, r, provider)*/ + render.NoContent(w, r) +} + +func updateProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + }*/ + render.NoContent(w, r) +} + +func healthCheckProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) + provider.HealthCheck()*/ + render.NoContent(w, r) +} + +func parseProviderName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := getEscapeParam(r, "name") + ctx := context.WithValue(r.Context(), CtxKeyProviderName, name) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func findProviderByName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + /*name := r.Context().Value(CtxKeyProviderName).(string) + providers := tunnel.ProxyProviders() + provider, exist := providers[name] + if !exist {*/ + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + //return + //} + + // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + // next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go new file mode 100644 index 00000000..ef88ff37 --- /dev/null +++ b/experimental/clashapi/proxies.go @@ -0,0 +1,234 @@ +package clashapi + +import ( + "context" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badjson" + N "github.com/sagernet/sing/common/network" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func proxyRouter(server *Server, router adapter.Router) http.Handler { + r := chi.NewRouter() + r.Get("/", getProxies(server)) + + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProxyName, findProxyByName(server)) + r.Get("/", getProxy(server)) + r.Get("/delay", getProxyDelay(server)) + r.Put("/", updateProxy) + }) + return r +} + +func parseProxyName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := getEscapeParam(r, "name") + ctx := context.WithValue(r.Context(), CtxKeyProxyName, name) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func findProxyByName(server *Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProxyName).(string) + proxy, exist := server.outbound.Outbound(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } + ctx := context.WithValue(r.Context(), CtxKeyProxy, proxy) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject { + var info badjson.JSONObject + var clashType string + switch detour.Type() { + case C.TypeBlock: + clashType = "Reject" + default: + clashType = C.ProxyDisplayName(detour.Type()) + } + info.Put("type", clashType) + info.Put("name", detour.Tag()) + info.Put("udp", common.Contains(detour.Network(), N.NetworkUDP)) + delayHistory := server.urlTestHistory.LoadURLTestHistory(adapter.OutboundTag(detour)) + if delayHistory != nil { + info.Put("history", []*adapter.URLTestHistory{delayHistory}) + } else { + info.Put("history", []*adapter.URLTestHistory{}) + } + if group, isGroup := detour.(adapter.OutboundGroup); isGroup { + info.Put("now", group.Now()) + info.Put("all", group.All()) + } + return &info +} + +func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var proxyMap badjson.JSONObject + outbounds := common.Filter(server.outbound.Outbounds(), func(detour adapter.Outbound) bool { + return detour.Tag() != "" + }) + outbounds = append(outbounds, common.Map(common.Filter(server.endpoint.Endpoints(), func(detour adapter.Endpoint) bool { + return detour.Tag() != "" + }), func(it adapter.Endpoint) adapter.Outbound { + return it + })...) + + allProxies := make([]string, 0, len(outbounds)) + + for _, detour := range outbounds { + switch detour.Type() { + case C.TypeDirect, C.TypeBlock, C.TypeDNS: + continue + } + allProxies = append(allProxies, detour.Tag()) + } + + defaultTag := server.outbound.Default().Tag() + + sort.SliceStable(allProxies, func(i, j int) bool { + return allProxies[i] == defaultTag + }) + + // fix clash dashboard + proxyMap.Put("GLOBAL", map[string]any{ + "type": "Fallback", + "name": "GLOBAL", + "udp": true, + "history": []*adapter.URLTestHistory{}, + "all": allProxies, + "now": defaultTag, + }) + + for i, detour := range outbounds { + var tag string + if detour.Tag() == "" { + tag = F.ToString(i) + } else { + tag = detour.Tag() + } + proxyMap.Put(tag, proxyInfo(server, detour)) + } + var responseMap badjson.JSONObject + responseMap.Put("proxies", &proxyMap) + response, err := responseMap.MarshalJSON() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + w.Write(response) + } +} + +func getProxy(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + response, err := proxyInfo(server, proxy).MarshalJSON() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + w.Write(response) + } +} + +type UpdateProxyRequest struct { + Name string `json:"name"` +} + +func updateProxy(w http.ResponseWriter, r *http.Request) { + req := UpdateProxyRequest{} + if err := render.DecodeJSON(r.Body, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + selector, ok := proxy.(*group.Selector) + if !ok { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("Must be a Selector")) + return + } + + if !selector.SelectOutbound(req.Name) { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("Selector update error: not found")) + return + } + + render.NoContent(w, r) +} + +func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + url := query.Get("url") + if strings.HasPrefix(url, "http://") { + url = "" + } + timeout, err := strconv.ParseInt(query.Get("timeout"), 10, 16) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(timeout)) + defer cancel() + + delay, err := urltest.URLTest(ctx, url, proxy) + defer func() { + realTag := group.RealTag(proxy) + if err != nil { + server.urlTestHistory.DeleteURLTestHistory(realTag) + } else { + server.urlTestHistory.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: delay, + }) + } + }() + + if ctx.Err() != nil { + render.Status(r, http.StatusGatewayTimeout) + render.JSON(w, r, ErrRequestTimeout) + return + } + + if err != nil || delay == 0 { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError("An error occurred in the delay test")) + return + } + + render.JSON(w, r, render.M{ + "delay": delay, + }) + } +} diff --git a/experimental/clashapi/ruleprovider.go b/experimental/clashapi/ruleprovider.go new file mode 100644 index 00000000..4a410854 --- /dev/null +++ b/experimental/clashapi/ruleprovider.go @@ -0,0 +1,58 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func ruleProviderRouter() http.Handler { + r := chi.NewRouter() + r.Get("/", getRuleProviders) + + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProviderName, findRuleProviderByName) + r.Get("/", getRuleProvider) + r.Put("/", updateRuleProvider) + }) + return r +} + +func getRuleProviders(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{ + "providers": []string{}, + }) +} + +func getRuleProvider(w http.ResponseWriter, r *http.Request) { + // provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) + // render.JSON(w, r, provider) + render.NoContent(w, r) +} + +func updateRuleProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + }*/ + render.NoContent(w, r) +} + +func findRuleProviderByName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + /*name := r.Context().Value(CtxKeyProviderName).(string) + providers := tunnel.RuleProviders() + provider, exist := providers[name] + if !exist {*/ + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + //return + //} + + // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + // next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/experimental/clashapi/rules.go b/experimental/clashapi/rules.go new file mode 100644 index 00000000..bc8fbb2b --- /dev/null +++ b/experimental/clashapi/rules.go @@ -0,0 +1,40 @@ +package clashapi + +import ( + "net/http" + + "github.com/sagernet/sing-box/adapter" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func ruleRouter(router adapter.Router) http.Handler { + r := chi.NewRouter() + r.Get("/", getRules(router)) + return r +} + +type Rule struct { + Type string `json:"type"` + Payload string `json:"payload"` + Proxy string `json:"proxy"` +} + +func getRules(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + rawRules := router.Rules() + + var rules []Rule + for _, rule := range rawRules { + rules = append(rules, Rule{ + Type: rule.Type(), + Payload: rule.String(), + Proxy: rule.Action().String(), + }) + } + render.JSON(w, r, render.M{ + "rules": rules, + }) + } +} diff --git a/experimental/clashapi/script.go b/experimental/clashapi/script.go new file mode 100644 index 00000000..a7b52f97 --- /dev/null +++ b/experimental/clashapi/script.go @@ -0,0 +1,98 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func scriptRouter() http.Handler { + r := chi.NewRouter() + r.Post("/", testScript) + r.Patch("/", patchScript) + return r +} + +/*type TestScriptRequest struct { + Script *string `json:"script"` + Metadata C.Metadata `json:"metadata"` +}*/ + +func testScript(w http.ResponseWriter, r *http.Request) { + /* req := TestScriptRequest{} + if err := render.DecodeJSON(r.Body, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + fn := tunnel.ScriptFn() + if req.Script == nil && fn == nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("should send `script`")) + return + } + + if !req.Metadata.Valid() { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("metadata not valid")) + return + } + + if req.Script != nil { + var err error + fn, err = script.ParseScript(*req.Script) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(err.Error())) + return + } + } + + ctx, _ := script.MakeContext(tunnel.ProxyProviders(), tunnel.RuleProviders()) + + thread := &starlark.Thread{} + ret, err := starlark.Call(thread, fn, starlark.Tuple{ctx, script.MakeMetadata(&req.Metadata)}, nil) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(err.Error())) + return + } + + elm, ok := ret.(starlark.String) + if !ok { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, "script fn must return a string") + return + } + + render.JSON(w, r, render.M{ + "result": string(elm), + })*/ + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("not implemented")) +} + +type PatchScriptRequest struct { + Script string `json:"script"` +} + +func patchScript(w http.ResponseWriter, r *http.Request) { + /*req := PatchScriptRequest{} + if err := render.DecodeJSON(r.Body, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + fn, err := script.ParseScript(req.Script) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(err.Error())) + return + } + + tunnel.UpdateScript(fn)*/ + render.NoContent(w, r) +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go new file mode 100644 index 00000000..ec40a95f --- /dev/null +++ b/experimental/clashapi/server.go @@ -0,0 +1,435 @@ +package clashapi + +import ( + "bytes" + "context" + "errors" + "net" + "net/http" + "os" + "runtime" + "strings" + "syscall" + "time" + + "github.com/sagernet/cors" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" + "github.com/sagernet/ws" + "github.com/sagernet/ws/wsutil" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func init() { + experimental.RegisterClashServerConstructor(NewServer) +} + +var _ adapter.ClashServer = (*Server)(nil) + +type Server struct { + ctx context.Context + router adapter.Router + dnsRouter adapter.DNSRouter + outbound adapter.OutboundManager + endpoint adapter.EndpointManager + logger log.Logger + httpServer *http.Server + trafficManager *trafficontrol.Manager + urlTestHistory adapter.URLTestHistoryStorage + logDebug bool + + mode string + modeList []string + modeUpdateHook *observable.Subscriber[struct{}] + + externalController bool + externalUI string + externalUIDownloadURL string + externalUIDownloadDetour string +} + +func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { + trafficManager := trafficontrol.NewManager() + chiRouter := chi.NewRouter() + s := &Server{ + ctx: ctx, + router: service.FromContext[adapter.Router](ctx), + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + outbound: service.FromContext[adapter.OutboundManager](ctx), + endpoint: service.FromContext[adapter.EndpointManager](ctx), + logger: logFactory.NewLogger("clash-api"), + httpServer: &http.Server{ + Addr: options.ExternalController, + Handler: chiRouter, + }, + trafficManager: trafficManager, + logDebug: logFactory.Level() >= log.LevelDebug, + modeList: options.ModeList, + externalController: options.ExternalController != "", + externalUIDownloadURL: options.ExternalUIDownloadURL, + externalUIDownloadDetour: options.ExternalUIDownloadDetour, + } + s.urlTestHistory = service.FromContext[adapter.URLTestHistoryStorage](ctx) + if s.urlTestHistory == nil { + s.urlTestHistory = urltest.NewHistoryStorage() + } + defaultMode := "Rule" + if options.DefaultMode != "" { + defaultMode = options.DefaultMode + } + if !common.Contains(s.modeList, defaultMode) { + s.modeList = append([]string{defaultMode}, s.modeList...) + } + s.mode = defaultMode + //goland:noinspection GoDeprecation + //nolint:staticcheck + if options.StoreMode || options.StoreSelected || options.StoreFakeIP || options.CacheFile != "" || options.CacheID != "" { + return nil, E.New("cache_file and related fields in Clash API is deprecated in sing-box 1.8.0, use experimental.cache_file instead.") + } + allowedOrigins := options.AccessControlAllowOrigin + if len(allowedOrigins) == 0 { + allowedOrigins = []string{"*"} + } + cors := cors.New(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowPrivateNetwork: options.AccessControlAllowPrivateNetwork, + MaxAge: 300, + }) + chiRouter.Use(cors.Handler) + chiRouter.Group(func(r chi.Router) { + r.Use(authentication(options.Secret)) + r.Get("/", hello(options.ExternalUI != "")) + r.Get("/logs", getLogs(s.ctx, logFactory)) + r.Get("/traffic", traffic(s.ctx, trafficManager)) + r.Get("/version", version) + r.Mount("/configs", configRouter(s, logFactory)) + r.Mount("/proxies", proxyRouter(s, s.router)) + r.Mount("/rules", ruleRouter(s.router)) + r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) + r.Mount("/providers/proxies", proxyProviderRouter()) + r.Mount("/providers/rules", ruleProviderRouter()) + r.Mount("/script", scriptRouter()) + r.Mount("/profile", profileRouter()) + r.Mount("/cache", cacheRouter(ctx)) + r.Mount("/dns", dnsRouter(s.dnsRouter)) + + s.setupMetaAPI(r) + }) + if options.ExternalUI != "" { + s.externalUI = filemanager.BasePath(ctx, os.ExpandEnv(options.ExternalUI)) + chiRouter.Group(func(r chi.Router) { + r.Get("/ui", http.RedirectHandler("/ui/", http.StatusMovedPermanently).ServeHTTP) + r.Handle("/ui/*", http.StripPrefix("/ui/", http.FileServer(Dir(s.externalUI)))) + }) + } + return s, nil +} + +func (s *Server) Name() string { + return "clash server" +} + +func (s *Server) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateStart: + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + mode := cacheFile.LoadMode() + if common.Any(s.modeList, func(it string) bool { + return strings.EqualFold(it, mode) + }) { + s.mode = mode + } + } + case adapter.StartStateStarted: + if s.externalController { + s.checkAndDownloadExternalUI() + var ( + listener net.Listener + err error + ) + for i := 0; i < 3; i++ { + listener, err = net.Listen("tcp", s.httpServer.Addr) + if runtime.GOOS == "android" && errors.Is(err, syscall.EADDRINUSE) { + time.Sleep(100 * time.Millisecond) + continue + } + break + } + if err != nil { + return E.Cause(err, "external controller listen error") + } + s.logger.Info("restful api listening at ", listener.Addr()) + go func() { + err = s.httpServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("external controller serve error: ", err) + } + }() + } + } + + return nil +} + +func (s *Server) Close() error { + return common.Close( + common.PtrOrNil(s.httpServer), + s.trafficManager, + s.urlTestHistory, + ) +} + +func (s *Server) Mode() string { + return s.mode +} + +func (s *Server) ModeList() []string { + return s.modeList +} + +func (s *Server) SetModeUpdateHook(hook *observable.Subscriber[struct{}]) { + s.modeUpdateHook = hook +} + +func (s *Server) SetMode(newMode string) { + if !common.Contains(s.modeList, newMode) { + newMode = common.Find(s.modeList, func(it string) bool { + return strings.EqualFold(it, newMode) + }) + } + if !common.Contains(s.modeList, newMode) { + return + } + if newMode == s.mode { + return + } + s.mode = newMode + if s.modeUpdateHook != nil { + s.modeUpdateHook.Emit(struct{}{}) + } + s.dnsRouter.ClearCache() + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + err := cacheFile.StoreMode(newMode) + if err != nil { + s.logger.Error(E.Cause(err, "save mode")) + } + } + s.logger.Info("updated mode: ", newMode) +} + +func (s *Server) HistoryStorage() adapter.URLTestHistoryStorage { + return s.urlTestHistory +} + +func (s *Server) TrafficManager() *trafficontrol.Manager { + return s.trafficManager +} + +func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + return trafficontrol.NewTCPTracker(conn, s.trafficManager, metadata, s.outbound, matchedRule, matchOutbound) +} + +func (s *Server) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn { + return trafficontrol.NewUDPTracker(conn, s.trafficManager, metadata, s.outbound, matchedRule, matchOutbound) +} + +func authentication(serverSecret string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + if serverSecret == "" { + next.ServeHTTP(w, r) + return + } + + // Browser websocket not support custom header + if r.Header.Get("Upgrade") == "websocket" && r.URL.Query().Get("token") != "" { + token := r.URL.Query().Get("token") + if token != serverSecret { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, ErrUnauthorized) + return + } + next.ServeHTTP(w, r) + return + } + + header := r.Header.Get("Authorization") + bearer, token, found := strings.Cut(header, " ") + + hasInvalidHeader := bearer != "Bearer" + hasInvalidSecret := !found || token != serverSecret + if hasInvalidHeader || hasInvalidSecret { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, ErrUnauthorized) + return + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } +} + +func hello(redirect bool) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + if !redirect || contentType == "application/json" { + render.JSON(w, r, render.M{"hello": "clash"}) + } else { + http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect) + } + } +} + +type Traffic struct { + Up int64 `json:"up"` + Down int64 `json:"down"` +} + +func traffic(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var conn net.Conn + if r.Header.Get("Upgrade") == "websocket" { + var err error + conn, _, _, err = ws.UpgradeHTTP(r, w) + if err != nil { + return + } + defer conn.Close() + } + + if conn == nil { + w.Header().Set("Content-Type", "application/json") + render.Status(r, http.StatusOK) + } + + tick := time.NewTicker(time.Second) + defer tick.Stop() + buf := &bytes.Buffer{} + uploadTotal, downloadTotal := trafficManager.Total() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + buf.Reset() + uploadTotalNew, downloadTotalNew := trafficManager.Total() + err := json.NewEncoder(buf).Encode(Traffic{ + Up: uploadTotalNew - uploadTotal, + Down: downloadTotalNew - downloadTotal, + }) + if err != nil { + break + } + if conn == nil { + _, err = w.Write(buf.Bytes()) + w.(http.Flusher).Flush() + } else { + err = wsutil.WriteServerText(conn, buf.Bytes()) + } + if err != nil { + break + } + + uploadTotal = uploadTotalNew + downloadTotal = downloadTotalNew + } + } +} + +type Log struct { + Type string `json:"type"` + Payload string `json:"payload"` +} + +func getLogs(ctx context.Context, logFactory log.ObservableFactory) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + levelText := r.URL.Query().Get("level") + if levelText == "" { + levelText = "info" + } + + level, ok := log.ParseLevel(levelText) + if ok != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + subscription, done, err := logFactory.Subscribe() + if err != nil { + render.Status(r, http.StatusNoContent) + return + } + defer logFactory.UnSubscribe(subscription) + + var conn net.Conn + if r.Header.Get("Upgrade") == "websocket" { + conn, _, _, err = ws.UpgradeHTTP(r, w) + if err != nil { + return + } + defer conn.Close() + } + + if conn == nil { + w.Header().Set("Content-Type", "application/json") + render.Status(r, http.StatusOK) + } + + buf := &bytes.Buffer{} + var logEntry log.Entry + for { + select { + case <-ctx.Done(): + return + case <-done: + return + case logEntry = <-subscription: + } + if logEntry.Level > level { + continue + } + buf.Reset() + err = json.NewEncoder(buf).Encode(Log{ + Type: log.FormatLevel(logEntry.Level), + Payload: logEntry.Message, + }) + if err != nil { + break + } + if conn == nil { + _, err = w.Write(buf.Bytes()) + w.(http.Flusher).Flush() + } else { + err = wsutil.WriteServerText(conn, buf.Bytes()) + } + + if err != nil { + break + } + } + } +} + +func version(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{"version": "sing-box " + C.Version, "premium": true, "meta": true}) +} diff --git a/experimental/clashapi/server_fs.go b/experimental/clashapi/server_fs.go new file mode 100644 index 00000000..afb4cca5 --- /dev/null +++ b/experimental/clashapi/server_fs.go @@ -0,0 +1,18 @@ +package clashapi + +import "net/http" + +type Dir http.Dir + +func (d Dir) Open(name string) (http.File, error) { + file, err := http.Dir(d).Open(name) + if err != nil { + return nil, err + } + return &fileWrapper{file}, nil +} + +// workaround for #2345 #2596 +type fileWrapper struct { + http.File +} diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go new file mode 100644 index 00000000..ad9fff53 --- /dev/null +++ b/experimental/clashapi/server_resources.go @@ -0,0 +1,170 @@ +package clashapi + +import ( + "archive/zip" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service/filemanager" +) + +func (s *Server) checkAndDownloadExternalUI() { + if s.externalUI == "" { + return + } + entries, err := os.ReadDir(s.externalUI) + if err != nil { + os.MkdirAll(s.externalUI, 0o755) + } + if len(entries) == 0 { + err = s.downloadExternalUI() + if err != nil { + s.logger.Error("download external ui error: ", err) + } + } +} + +func (s *Server) downloadExternalUI() error { + var downloadURL string + if s.externalUIDownloadURL != "" { + downloadURL = s.externalUIDownloadURL + } else { + downloadURL = "https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip" + } + var detour adapter.Outbound + if s.externalUIDownloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.externalUIDownloadDetour) + if !loaded { + return E.New("detour outbound not found: ", s.externalUIDownloadDetour) + } + detour = outbound + } else { + outbound := s.outbound.Default() + detour = outbound + } + s.logger.Info("downloading external ui using outbound/", detour.Type(), "[", detour.Tag(), "]") + httpClient := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(s.ctx), + RootCAs: adapter.RootPoolFromContext(s.ctx), + }, + }, + } + defer httpClient.CloseIdleConnections() + response, err := httpClient.Get(downloadURL) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return E.New("download external ui failed: ", response.Status) + } + err = s.downloadZIP(response.Body, s.externalUI) + if err != nil { + removeAllInDirectory(s.externalUI) + } + return err +} + +func (s *Server) downloadZIP(body io.Reader, output string) error { + tempFile, err := filemanager.CreateTemp(s.ctx, "external-ui.zip") + if err != nil { + return err + } + defer os.Remove(tempFile.Name()) + _, err = io.Copy(tempFile, body) + tempFile.Close() + if err != nil { + return err + } + reader, err := zip.OpenReader(tempFile.Name()) + if err != nil { + return err + } + defer reader.Close() + trimDir := zipIsInSingleDirectory(reader.File) + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + pathElements := strings.Split(file.Name, "/") + if trimDir { + pathElements = pathElements[1:] + } + saveDirectory := output + if len(pathElements) > 1 { + saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) + } + err = os.MkdirAll(saveDirectory, 0o755) + if err != nil { + return err + } + savePath := filepath.Join(saveDirectory, pathElements[len(pathElements)-1]) + err = downloadZIPEntry(s.ctx, file, savePath) + if err != nil { + return err + } + } + return nil +} + +func downloadZIPEntry(ctx context.Context, zipFile *zip.File, savePath string) error { + saveFile, err := filemanager.Create(ctx, savePath) + if err != nil { + return err + } + defer saveFile.Close() + reader, err := zipFile.Open() + if err != nil { + return err + } + defer reader.Close() + return common.Error(io.Copy(saveFile, reader)) +} + +func removeAllInDirectory(directory string) { + dirEntries, err := os.ReadDir(directory) + if err != nil { + return + } + for _, dirEntry := range dirEntries { + os.RemoveAll(filepath.Join(directory, dirEntry.Name())) + } +} + +func zipIsInSingleDirectory(files []*zip.File) bool { + var singleDirectory string + for _, file := range files { + if file.FileInfo().IsDir() { + continue + } + pathElements := strings.Split(file.Name, "/") + if len(pathElements) == 0 { + return false + } + if singleDirectory == "" { + singleDirectory = pathElements[0] + } else if singleDirectory != pathElements[0] { + return false + } + } + return true +} diff --git a/experimental/clashapi/trafficontrol/manager.go b/experimental/clashapi/trafficontrol/manager.go new file mode 100644 index 00000000..6763436d --- /dev/null +++ b/experimental/clashapi/trafficontrol/manager.go @@ -0,0 +1,181 @@ +package trafficontrol + +import ( + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/common/compatible" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/common/x/list" + + "github.com/gofrs/uuid/v5" +) + +type ConnectionEventType int + +const ( + ConnectionEventNew ConnectionEventType = iota + ConnectionEventUpdate + ConnectionEventClosed +) + +type ConnectionEvent struct { + Type ConnectionEventType + ID uuid.UUID + Metadata *TrackerMetadata + UplinkDelta int64 + DownlinkDelta int64 + ClosedAt time.Time +} + +const closedConnectionsLimit = 1000 + +type Manager struct { + uploadTotal atomic.Int64 + downloadTotal atomic.Int64 + + connections compatible.Map[uuid.UUID, Tracker] + closedConnectionsAccess sync.Mutex + closedConnections list.List[TrackerMetadata] + memory uint64 + + eventSubscriber *observable.Subscriber[ConnectionEvent] +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) SetEventHook(subscriber *observable.Subscriber[ConnectionEvent]) { + m.eventSubscriber = subscriber +} + +func (m *Manager) Join(c Tracker) { + metadata := c.Metadata() + m.connections.Store(metadata.ID, c) + if m.eventSubscriber != nil { + m.eventSubscriber.Emit(ConnectionEvent{ + Type: ConnectionEventNew, + ID: metadata.ID, + Metadata: metadata, + }) + } +} + +func (m *Manager) Leave(c Tracker) { + metadata := c.Metadata() + _, loaded := m.connections.LoadAndDelete(metadata.ID) + if loaded { + closedAt := time.Now() + metadata.ClosedAt = closedAt + metadataCopy := *metadata + m.closedConnectionsAccess.Lock() + if m.closedConnections.Len() >= closedConnectionsLimit { + m.closedConnections.PopFront() + } + m.closedConnections.PushBack(metadataCopy) + m.closedConnectionsAccess.Unlock() + if m.eventSubscriber != nil { + m.eventSubscriber.Emit(ConnectionEvent{ + Type: ConnectionEventClosed, + ID: metadata.ID, + Metadata: &metadataCopy, + ClosedAt: closedAt, + }) + } + } +} + +func (m *Manager) PushUploaded(size int64) { + m.uploadTotal.Add(size) +} + +func (m *Manager) PushDownloaded(size int64) { + m.downloadTotal.Add(size) +} + +func (m *Manager) Total() (up int64, down int64) { + return m.uploadTotal.Load(), m.downloadTotal.Load() +} + +func (m *Manager) ConnectionsLen() int { + return m.connections.Len() +} + +func (m *Manager) Connections() []*TrackerMetadata { + var connections []*TrackerMetadata + m.connections.Range(func(_ uuid.UUID, value Tracker) bool { + connections = append(connections, value.Metadata()) + return true + }) + return connections +} + +func (m *Manager) ClosedConnections() []*TrackerMetadata { + m.closedConnectionsAccess.Lock() + values := m.closedConnections.Array() + m.closedConnectionsAccess.Unlock() + if len(values) == 0 { + return nil + } + connections := make([]*TrackerMetadata, len(values)) + for i := range values { + connections[i] = &values[i] + } + return connections +} + +func (m *Manager) Connection(id uuid.UUID) Tracker { + connection, loaded := m.connections.Load(id) + if !loaded { + return nil + } + return connection +} + +func (m *Manager) Snapshot() *Snapshot { + var connections []Tracker + m.connections.Range(func(_ uuid.UUID, value Tracker) bool { + if value.Metadata().OutboundType != C.TypeDNS { + connections = append(connections, value) + } + return true + }) + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + m.memory = memStats.StackInuse + memStats.HeapInuse + memStats.HeapIdle - memStats.HeapReleased + + return &Snapshot{ + Upload: m.uploadTotal.Load(), + Download: m.downloadTotal.Load(), + Connections: connections, + Memory: m.memory, + } +} + +func (m *Manager) ResetStatistic() { + m.uploadTotal.Store(0) + m.downloadTotal.Store(0) +} + +type Snapshot struct { + Download int64 + Upload int64 + Connections []Tracker + Memory uint64 +} + +func (s *Snapshot) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "downloadTotal": s.Download, + "uploadTotal": s.Upload, + "connections": common.Map(s.Connections, func(t Tracker) *TrackerMetadata { return t.Metadata() }), + "memory": s.Memory, + }) +} diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go new file mode 100644 index 00000000..f001b77b --- /dev/null +++ b/experimental/clashapi/trafficontrol/tracker.go @@ -0,0 +1,254 @@ +package trafficontrol + +import ( + "net" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + N "github.com/sagernet/sing/common/network" + + "github.com/gofrs/uuid/v5" +) + +type TrackerMetadata struct { + ID uuid.UUID + Metadata adapter.InboundContext + CreatedAt time.Time + ClosedAt time.Time + Upload *atomic.Int64 + Download *atomic.Int64 + Chain []string + Rule adapter.Rule + Outbound string + OutboundType string +} + +func (t TrackerMetadata) MarshalJSON() ([]byte, error) { + var inbound string + if t.Metadata.Inbound != "" { + inbound = t.Metadata.InboundType + "/" + t.Metadata.Inbound + } else { + inbound = t.Metadata.InboundType + } + var domain string + if t.Metadata.Domain != "" { + domain = t.Metadata.Domain + } else { + domain = t.Metadata.Destination.Fqdn + } + var processPath string + if t.Metadata.ProcessInfo != nil { + if t.Metadata.ProcessInfo.ProcessPath != "" { + processPath = t.Metadata.ProcessInfo.ProcessPath + } else if len(t.Metadata.ProcessInfo.AndroidPackageNames) > 0 { + processPath = t.Metadata.ProcessInfo.AndroidPackageNames[0] + } + if processPath == "" { + if t.Metadata.ProcessInfo.UserId != -1 { + processPath = F.ToString(t.Metadata.ProcessInfo.UserId) + } + } else if t.Metadata.ProcessInfo.UserName != "" { + processPath = F.ToString(processPath, " (", t.Metadata.ProcessInfo.UserName, ")") + } else if t.Metadata.ProcessInfo.UserId != -1 { + processPath = F.ToString(processPath, " (", t.Metadata.ProcessInfo.UserId, ")") + } + } + var rule string + if t.Rule != nil { + rule = F.ToString(t.Rule, " => ", t.Rule.Action()) + } else { + rule = "final" + } + return json.Marshal(map[string]any{ + "id": t.ID, + "metadata": map[string]any{ + "network": t.Metadata.Network, + "type": inbound, + "sourceIP": t.Metadata.Source.Addr, + "destinationIP": t.Metadata.Destination.Addr, + "sourcePort": F.ToString(t.Metadata.Source.Port), + "destinationPort": F.ToString(t.Metadata.Destination.Port), + "host": domain, + "dnsMode": "normal", + "processPath": processPath, + }, + "upload": t.Upload.Load(), + "download": t.Download.Load(), + "start": t.CreatedAt, + "chains": t.Chain, + "rule": rule, + "rulePayload": "", + }) +} + +type Tracker interface { + Metadata() *TrackerMetadata + Close() error +} + +type TCPConn struct { + N.ExtendedConn + metadata TrackerMetadata + manager *Manager +} + +func (tt *TCPConn) Metadata() *TrackerMetadata { + return &tt.metadata +} + +func (tt *TCPConn) Close() error { + tt.manager.Leave(tt) + return tt.ExtendedConn.Close() +} + +func (tt *TCPConn) Upstream() any { + return tt.ExtendedConn +} + +func (tt *TCPConn) ReaderReplaceable() bool { + return true +} + +func (tt *TCPConn) WriterReplaceable() bool { + return true +} + +func NewTCPTracker(conn net.Conn, manager *Manager, metadata adapter.InboundContext, outboundManager adapter.OutboundManager, matchRule adapter.Rule, matchOutbound adapter.Outbound) *TCPConn { + id, _ := uuid.NewV4() + var ( + chain []string + next string + outbound string + outboundType string + ) + if matchOutbound != nil { + next = matchOutbound.Tag() + } else { + next = outboundManager.Default().Tag() + } + for { + detour, loaded := outboundManager.Outbound(next) + if !loaded { + break + } + chain = append(chain, next) + outbound = detour.Tag() + outboundType = detour.Type() + group, isGroup := detour.(adapter.OutboundGroup) + if !isGroup { + break + } + next = group.Now() + } + upload := new(atomic.Int64) + download := new(atomic.Int64) + tracker := &TCPConn{ + ExtendedConn: bufio.NewCounterConn(conn, []N.CountFunc{func(n int64) { + upload.Add(n) + manager.PushUploaded(n) + }}, []N.CountFunc{func(n int64) { + download.Add(n) + manager.PushDownloaded(n) + }}), + metadata: TrackerMetadata{ + ID: id, + Metadata: metadata, + CreatedAt: time.Now(), + Upload: upload, + Download: download, + Chain: common.Reverse(chain), + Rule: matchRule, + Outbound: outbound, + OutboundType: outboundType, + }, + manager: manager, + } + manager.Join(tracker) + return tracker +} + +type UDPConn struct { + N.PacketConn `json:"-"` + metadata TrackerMetadata + manager *Manager +} + +func (ut *UDPConn) Metadata() *TrackerMetadata { + return &ut.metadata +} + +func (ut *UDPConn) Close() error { + ut.manager.Leave(ut) + return ut.PacketConn.Close() +} + +func (ut *UDPConn) Upstream() any { + return ut.PacketConn +} + +func (ut *UDPConn) ReaderReplaceable() bool { + return true +} + +func (ut *UDPConn) WriterReplaceable() bool { + return true +} + +func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata adapter.InboundContext, outboundManager adapter.OutboundManager, matchRule adapter.Rule, matchOutbound adapter.Outbound) *UDPConn { + id, _ := uuid.NewV4() + var ( + chain []string + next string + outbound string + outboundType string + ) + if matchOutbound != nil { + next = matchOutbound.Tag() + } else { + next = outboundManager.Default().Tag() + } + for { + detour, loaded := outboundManager.Outbound(next) + if !loaded { + break + } + chain = append(chain, next) + outbound = detour.Tag() + outboundType = detour.Type() + group, isGroup := detour.(adapter.OutboundGroup) + if !isGroup { + break + } + next = group.Now() + } + upload := new(atomic.Int64) + download := new(atomic.Int64) + trackerConn := &UDPConn{ + PacketConn: bufio.NewCounterPacketConn(conn, []N.CountFunc{func(n int64) { + upload.Add(n) + manager.PushUploaded(n) + }}, []N.CountFunc{func(n int64) { + download.Add(n) + manager.PushDownloaded(n) + }}), + metadata: TrackerMetadata{ + ID: id, + Metadata: metadata, + CreatedAt: time.Now(), + Upload: upload, + Download: download, + Chain: common.Reverse(chain), + Rule: matchRule, + Outbound: outbound, + OutboundType: outboundType, + }, + manager: manager, + } + manager.Join(trackerConn) + return trackerConn +} diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go new file mode 100644 index 00000000..108eba57 --- /dev/null +++ b/experimental/deprecated/constants.go @@ -0,0 +1,151 @@ +package deprecated + +import ( + "fmt" + + "github.com/sagernet/sing-box/common/badversion" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/locale" + F "github.com/sagernet/sing/common/format" + + "golang.org/x/mod/semver" +) + +type Note struct { + Name string + Description string + DeprecatedVersion string + ScheduledVersion string + EnvName string + MigrationLink string +} + +func (n Note) Impending() bool { + if n.ScheduledVersion == "" { + return false + } + if !semver.IsValid("v" + C.Version) { + return false + } + versionCurrent := badversion.Parse(C.Version) + versionMinor := badversion.Parse(n.ScheduledVersion).Minor - versionCurrent.Minor + if versionCurrent.PreReleaseIdentifier == "" && versionMinor < 0 { + panic("invalid deprecated note: " + n.Name) + } + return versionMinor <= 1 +} + +func (n Note) Message() string { + if n.MigrationLink != "" { + return fmt.Sprintf(locale.Current().DeprecatedMessage, n.Description, n.DeprecatedVersion, n.ScheduledVersion) + } else { + return fmt.Sprintf(locale.Current().DeprecatedMessageNoLink, n.Description, n.DeprecatedVersion, n.ScheduledVersion) + } +} + +func (n Note) MessageWithLink() string { + if n.MigrationLink != "" { + return F.ToString( + n.Description, " is deprecated in sing-box ", n.DeprecatedVersion, + " and will be removed in sing-box ", n.ScheduledVersion, ", checkout documentation for migration: ", n.MigrationLink, + ) + } else { + return F.ToString( + n.Description, " is deprecated in sing-box ", n.DeprecatedVersion, + " and will be removed in sing-box ", n.ScheduledVersion, ".", + ) + } +} + +var OptionOutboundDNSRuleItem = Note{ + Name: "outbound-dns-rule-item", + Description: "outbound DNS rule item", + DeprecatedVersion: "1.12.0", + ScheduledVersion: "1.14.0", + EnvName: "OUTBOUND_DNS_RULE_ITEM", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-outbound-dns-rule-items-to-domain-resolver", +} + +var OptionMissingDomainResolver = Note{ + Name: "missing-domain-resolver", + Description: "missing `route.default_domain_resolver` or `domain_resolver` in dial fields", + DeprecatedVersion: "1.12.0", + ScheduledVersion: "1.14.0", + EnvName: "MISSING_DOMAIN_RESOLVER", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-outbound-dns-rule-items-to-domain-resolver", +} + +var OptionLegacyDomainStrategyOptions = Note{ + Name: "legacy-domain-strategy-options", + Description: "legacy domain strategy options", + DeprecatedVersion: "1.12.0", + ScheduledVersion: "1.14.0", + EnvName: "LEGACY_DOMAIN_STRATEGY_OPTIONS", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-domain-strategy-options", +} + +var OptionInlineACME = Note{ + Name: "inline-acme-options", + Description: "inline ACME options in TLS", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "INLINE_ACME_OPTIONS", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-inline-acme-to-certificate-provider", +} + +var OptionRuleSetIPCIDRAcceptEmpty = Note{ + Name: "dns-rule-rule-set-ip-cidr-accept-empty", + Description: "Legacy `rule_set_ip_cidr_accept_empty` DNS rule item", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "DNS_RULE_RULE_SET_IP_CIDR_ACCEPT_EMPTY", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", +} + +var OptionLegacyDNSAddressFilter = Note{ + Name: "legacy-dns-address-filter", + Description: "Legacy Address Filter Fields in DNS rules", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "LEGACY_DNS_ADDRESS_FILTER", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", +} + +var OptionLegacyDNSRuleStrategy = Note{ + Name: "legacy-dns-rule-strategy", + Description: "Legacy `strategy` DNS rule action option", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "LEGACY_DNS_RULE_STRATEGY", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-dns-rule-action-strategy-to-rule-items", +} + +var OptionIndependentDNSCache = Note{ + Name: "independent-dns-cache", + Description: "`independent_cache` DNS option", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "INDEPENDENT_DNS_CACHE", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-independent-dns-cache", +} + +var OptionStoreRDRC = Note{ + Name: "store-rdrc", + Description: "`store_rdrc` cache file option", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "STORE_RDRC", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-store-rdrc", +} + +var Options = []Note{ + OptionOutboundDNSRuleItem, + OptionMissingDomainResolver, + OptionLegacyDomainStrategyOptions, + OptionInlineACME, + OptionRuleSetIPCIDRAcceptEmpty, + OptionLegacyDNSAddressFilter, + OptionLegacyDNSRuleStrategy, + OptionIndependentDNSCache, + OptionStoreRDRC, +} diff --git a/experimental/deprecated/manager.go b/experimental/deprecated/manager.go new file mode 100644 index 00000000..d12acf48 --- /dev/null +++ b/experimental/deprecated/manager.go @@ -0,0 +1,19 @@ +package deprecated + +import ( + "context" + + "github.com/sagernet/sing/service" +) + +type Manager interface { + ReportDeprecated(feature Note) +} + +func Report(ctx context.Context, feature Note) { + manager := service.FromContext[Manager](ctx) + if manager == nil { + return + } + manager.ReportDeprecated(feature) +} diff --git a/experimental/deprecated/stderr.go b/experimental/deprecated/stderr.go new file mode 100644 index 00000000..0dfb9354 --- /dev/null +++ b/experimental/deprecated/stderr.go @@ -0,0 +1,42 @@ +package deprecated + +import ( + "os" + "strconv" + + "github.com/sagernet/sing/common/logger" +) + +type stderrManager struct { + logger logger.Logger + reported map[string]bool +} + +func NewStderrManager(logger logger.Logger) Manager { + return &stderrManager{ + logger: logger, + reported: make(map[string]bool), + } +} + +func (f *stderrManager) ReportDeprecated(feature Note) { + if f.reported[feature.Name] { + return + } + f.reported[feature.Name] = true + if !feature.Impending() { + f.logger.Warn(feature.MessageWithLink()) + return + } + if feature.EnvName != "" { + enable, enableErr := strconv.ParseBool(os.Getenv("ENABLE_DEPRECATED_" + feature.EnvName)) + if enableErr == nil && enable { + f.logger.Warn(feature.MessageWithLink()) + return + } + f.logger.Error(feature.MessageWithLink()) + f.logger.Fatal("to continuing using this feature, set environment variable ENABLE_DEPRECATED_" + feature.EnvName + "=true") + } else { + f.logger.Error(feature.MessageWithLink()) + } +} diff --git a/experimental/libbox/build_info.go b/experimental/libbox/build_info.go new file mode 100644 index 00000000..5a02593c --- /dev/null +++ b/experimental/libbox/build_info.go @@ -0,0 +1,234 @@ +//go:build android + +package libbox + +import ( + "archive/zip" + "bytes" + "debug/buildinfo" + "io" + "runtime/debug" + "strings" + + "github.com/sagernet/sing/common" +) + +const ( + androidVPNCoreTypeOpenVPN = "OpenVPN" + androidVPNCoreTypeShadowsocks = "Shadowsocks" + androidVPNCoreTypeClash = "Clash" + androidVPNCoreTypeV2Ray = "V2Ray" + androidVPNCoreTypeWireGuard = "WireGuard" + androidVPNCoreTypeSingBox = "sing-box" + androidVPNCoreTypeUnknown = "Unknown" +) + +type AndroidVPNType struct { + CoreType string + CorePath string + GoVersion string +} + +func ReadAndroidVPNType(publicSourceDirList StringIterator) (*AndroidVPNType, error) { + apkPathList := iteratorToArray[string](publicSourceDirList) + var lastError error + for _, apkPath := range apkPathList { + androidVPNType, err := readAndroidVPNType(apkPath) + if androidVPNType == nil { + if err != nil { + lastError = err + } + continue + } + return androidVPNType, nil + } + return nil, lastError +} + +func readAndroidVPNType(publicSourceDir string) (*AndroidVPNType, error) { + reader, err := zip.OpenReader(publicSourceDir) + if err != nil { + return nil, err + } + defer reader.Close() + var lastError error + for _, file := range reader.File { + if !strings.HasPrefix(file.Name, "lib/") { + continue + } + vpnType, err := readAndroidVPNTypeEntry(file) + if err != nil { + lastError = err + continue + } + return vpnType, nil + } + for _, file := range reader.File { + if !strings.HasPrefix(file.Name, "lib/") { + continue + } + if strings.Contains(file.Name, androidVPNCoreTypeOpenVPN) || strings.Contains(file.Name, "ovpn") { + return &AndroidVPNType{CoreType: androidVPNCoreTypeOpenVPN}, nil + } + if strings.Contains(file.Name, androidVPNCoreTypeShadowsocks) { + return &AndroidVPNType{CoreType: androidVPNCoreTypeShadowsocks}, nil + } + } + return nil, lastError +} + +func readAndroidVPNTypeEntry(zipFile *zip.File) (*AndroidVPNType, error) { + readCloser, err := zipFile.Open() + if err != nil { + return nil, err + } + libContent := make([]byte, zipFile.UncompressedSize64) + _, err = io.ReadFull(readCloser, libContent) + readCloser.Close() + if err != nil { + return nil, err + } + buildInfo, err := buildinfo.Read(bytes.NewReader(libContent)) + if err != nil { + return nil, err + } + var vpnType AndroidVPNType + vpnType.GoVersion = buildInfo.GoVersion + if !strings.HasPrefix(vpnType.GoVersion, "go") { + vpnType.GoVersion = "obfuscated" + } else { + vpnType.GoVersion = vpnType.GoVersion[2:] + } + vpnType.CoreType = androidVPNCoreTypeUnknown + if len(buildInfo.Deps) == 0 { + vpnType.CoreType = "obfuscated" + return &vpnType, nil + } + + dependencies := make(map[string]bool) + dependencies[buildInfo.Path] = true + for _, module := range buildInfo.Deps { + dependencies[module.Path] = true + if module.Replace != nil { + dependencies[module.Replace.Path] = true + } + } + for dependency := range dependencies { + pkgType, loaded := determinePkgType(dependency) + if loaded { + vpnType.CoreType = pkgType + } + } + if vpnType.CoreType == androidVPNCoreTypeUnknown { + for dependency := range dependencies { + pkgType, loaded := determinePkgTypeSecondary(dependency) + if loaded { + vpnType.CoreType = pkgType + return &vpnType, nil + } + } + } + if vpnType.CoreType != androidVPNCoreTypeUnknown { + vpnType.CorePath, _ = determineCorePath(buildInfo, vpnType.CoreType) + return &vpnType, nil + } + if dependencies["github.com/golang/protobuf"] && dependencies["github.com/v2fly/ss-bloomring"] { + vpnType.CoreType = androidVPNCoreTypeV2Ray + return &vpnType, nil + } + return &vpnType, nil +} + +func determinePkgType(pkgName string) (string, bool) { + pkgNameLower := strings.ToLower(pkgName) + if strings.Contains(pkgNameLower, "clash") { + return androidVPNCoreTypeClash, true + } + if strings.Contains(pkgNameLower, "v2ray") || strings.Contains(pkgNameLower, "xray") { + return androidVPNCoreTypeV2Ray, true + } + + if strings.Contains(pkgNameLower, "sing-box") { + return androidVPNCoreTypeSingBox, true + } + return "", false +} + +func determinePkgTypeSecondary(pkgName string) (string, bool) { + pkgNameLower := strings.ToLower(pkgName) + if strings.Contains(pkgNameLower, "wireguard") { + return androidVPNCoreTypeWireGuard, true + } + return "", false +} + +func determineCorePath(pkgInfo *buildinfo.BuildInfo, pkgType string) (string, bool) { + switch pkgType { + case androidVPNCoreTypeClash: + return determineCorePathForPkgs(pkgInfo, []string{"github.com/Dreamacro/clash"}, []string{"clash"}) + case androidVPNCoreTypeV2Ray: + if v2rayVersion, loaded := determineCorePathForPkgs(pkgInfo, []string{ + "github.com/v2fly/v2ray-core", + "github.com/v2fly/v2ray-core/v4", + "github.com/v2fly/v2ray-core/v5", + }, []string{ + "v2ray", + }); loaded { + return v2rayVersion, true + } + if xrayVersion, loaded := determineCorePathForPkgs(pkgInfo, []string{ + "github.com/xtls/xray-core", + }, []string{ + "xray", + }); loaded { + return xrayVersion, true + } + return "", false + case androidVPNCoreTypeSingBox: + return determineCorePathForPkgs(pkgInfo, []string{"github.com/sagernet/sing-box"}, []string{"sing-box"}) + case androidVPNCoreTypeWireGuard: + return determineCorePathForPkgs(pkgInfo, []string{"golang.zx2c4.com/wireguard"}, []string{"wireguard"}) + default: + return "", false + } +} + +func determineCorePathForPkgs(pkgInfo *buildinfo.BuildInfo, pkgs []string, names []string) (string, bool) { + for _, pkg := range pkgs { + if pkgInfo.Path == pkg { + return pkg, true + } + strictDependency := common.Find(pkgInfo.Deps, func(module *debug.Module) bool { + return module.Path == pkg + }) + if strictDependency != nil { + if isValidVersion(strictDependency.Version) { + return strictDependency.Path + " " + strictDependency.Version, true + } else { + return strictDependency.Path, true + } + } + } + for _, name := range names { + if strings.Contains(pkgInfo.Path, name) { + return pkgInfo.Path, true + } + looseDependency := common.Find(pkgInfo.Deps, func(module *debug.Module) bool { + return strings.Contains(module.Path, name) || (module.Replace != nil && strings.Contains(module.Replace.Path, name)) + }) + if looseDependency != nil { + return looseDependency.Path, true + } + } + return "", false +} + +func isValidVersion(version string) bool { + if version == "(devel)" { + return false + } + if strings.Contains(version, "v0.0.0") { + return false + } + return true +} diff --git a/experimental/libbox/command.go b/experimental/libbox/command.go new file mode 100644 index 00000000..8a43bc95 --- /dev/null +++ b/experimental/libbox/command.go @@ -0,0 +1,10 @@ +package libbox + +const ( + CommandLog int32 = iota + CommandStatus + CommandGroup + CommandClashMode + CommandConnections + CommandOutbounds +) diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go new file mode 100644 index 00000000..d4347e10 --- /dev/null +++ b/experimental/libbox/command_client.go @@ -0,0 +1,807 @@ +package libbox + +import ( + "context" + "net" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +type CommandClient struct { + handler CommandClientHandler + grpcConn *grpc.ClientConn + grpcClient daemon.StartedServiceClient + options CommandClientOptions + ctx context.Context + cancel context.CancelFunc + clientMutex sync.RWMutex + standalone bool +} + +type CommandClientOptions struct { + commands []int32 + StatusInterval int64 +} + +func (o *CommandClientOptions) AddCommand(command int32) { + o.commands = append(o.commands, command) +} + +type CommandClientHandler interface { + Connected() + Disconnected(message string) + SetDefaultLogLevel(level int32) + ClearLogs() + WriteLogs(messageList LogIterator) + WriteStatus(message *StatusMessage) + WriteGroups(message OutboundGroupIterator) + WriteOutbounds(message OutboundGroupItemIterator) + InitializeClashMode(modeList StringIterator, currentMode string) + UpdateClashMode(newMode string) + WriteConnectionEvents(events *ConnectionEvents) +} + +type LogEntry struct { + Level int32 + Message string +} + +type LogIterator interface { + Len() int32 + HasNext() bool + Next() *LogEntry +} + +type XPCDialer interface { + DialXPC() (int32, error) +} + +var sXPCDialer XPCDialer + +func SetXPCDialer(dialer XPCDialer) { + sXPCDialer = dialer +} + +func NewStandaloneCommandClient() *CommandClient { + return &CommandClient{standalone: true} +} + +func NewCommandClient(handler CommandClientHandler, options *CommandClientOptions) *CommandClient { + return &CommandClient{ + handler: handler, + options: common.PtrValueOrDefault(options), + } +} + +func unaryClientAuthInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if sCommandServerSecret != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "x-command-secret", sCommandServerSecret) + } + return invoker(ctx, method, req, reply, cc, opts...) +} + +func streamClientAuthInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + if sCommandServerSecret != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "x-command-secret", sCommandServerSecret) + } + return streamer(ctx, desc, cc, method, opts...) +} + +const ( + commandClientDialAttempts = 10 + commandClientDialBaseDelay = 100 * time.Millisecond + commandClientDialStepDelay = 50 * time.Millisecond +) + +func commandClientDialDelay(attempt int) time.Duration { + return commandClientDialBaseDelay + time.Duration(attempt)*commandClientDialStepDelay +} + +func dialTarget() (string, func(context.Context, string) (net.Conn, error)) { + if sXPCDialer != nil { + return "passthrough:///xpc", func(ctx context.Context, _ string) (net.Conn, error) { + fileDescriptor, err := sXPCDialer.DialXPC() + if err != nil { + return nil, E.Cause(err, "dial xpc") + } + return networkConnectionFromFileDescriptor(fileDescriptor) + } + } + if sCommandServerListenPort == 0 { + socketPath := filepath.Join(sBasePath, "command.sock") + return "passthrough:///command-socket", func(ctx context.Context, _ string) (net.Conn, error) { + var networkDialer net.Dialer + return networkDialer.DialContext(ctx, "unix", socketPath) + } + } + return net.JoinHostPort("127.0.0.1", strconv.Itoa(int(sCommandServerListenPort))), nil +} + +func networkConnectionFromFileDescriptor(fileDescriptor int32) (net.Conn, error) { + file := os.NewFile(uintptr(fileDescriptor), "xpc-command-socket") + if file == nil { + return nil, E.New("invalid file descriptor") + } + networkConnection, err := net.FileConn(file) + if err != nil { + file.Close() + return nil, E.Cause(err, "create connection from fd") + } + file.Close() + return networkConnection, nil +} + +func (c *CommandClient) dialWithRetry(target string, contextDialer func(context.Context, string) (net.Conn, error), retryDial bool) (*grpc.ClientConn, daemon.StartedServiceClient, error) { + var connection *grpc.ClientConn + var client daemon.StartedServiceClient + var lastError error + + for attempt := 0; attempt < commandClientDialAttempts; attempt++ { + if connection == nil { + options := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(unaryClientAuthInterceptor), + grpc.WithStreamInterceptor(streamClientAuthInterceptor), + } + if contextDialer != nil { + options = append(options, grpc.WithContextDialer(contextDialer)) + } + var err error + connection, err = grpc.NewClient(target, options...) + if err != nil { + lastError = err + if !retryDial { + return nil, nil, E.Cause(err, "create command client") + } + time.Sleep(commandClientDialDelay(attempt)) + continue + } + client = daemon.NewStartedServiceClient(connection) + } + waitDuration := commandClientDialDelay(attempt) + ctx, cancel := context.WithTimeout(context.Background(), waitDuration) + _, err := client.GetStartedAt(ctx, &emptypb.Empty{}, grpc.WaitForReady(true)) + cancel() + if err == nil { + return connection, client, nil + } + lastError = err + } + + if connection != nil { + connection.Close() + } + return nil, nil, E.Cause(lastError, "probe command server") +} + +func (c *CommandClient) Connect() error { + c.clientMutex.Lock() + common.Close(common.PtrOrNil(c.grpcConn)) + + target, contextDialer := dialTarget() + connection, client, err := c.dialWithRetry(target, contextDialer, true) + if err != nil { + c.clientMutex.Unlock() + return err + } + c.grpcConn = connection + c.grpcClient = client + c.ctx, c.cancel = context.WithCancel(context.Background()) + c.clientMutex.Unlock() + + c.handler.Connected() + return c.dispatchCommands() +} + +func (c *CommandClient) ConnectWithFD(fd int32) error { + c.clientMutex.Lock() + common.Close(common.PtrOrNil(c.grpcConn)) + + networkConnection, err := networkConnectionFromFileDescriptor(fd) + if err != nil { + c.clientMutex.Unlock() + return err + } + connection, client, err := c.dialWithRetry("passthrough:///xpc", func(ctx context.Context, _ string) (net.Conn, error) { + return networkConnection, nil + }, false) + if err != nil { + networkConnection.Close() + c.clientMutex.Unlock() + return err + } + c.grpcConn = connection + c.grpcClient = client + c.ctx, c.cancel = context.WithCancel(context.Background()) + c.clientMutex.Unlock() + + c.handler.Connected() + return c.dispatchCommands() +} + +func (c *CommandClient) dispatchCommands() error { + for _, command := range c.options.commands { + switch command { + case CommandLog: + go c.handleLogStream() + case CommandStatus: + go c.handleStatusStream() + case CommandGroup: + go c.handleGroupStream() + case CommandClashMode: + go c.handleClashModeStream() + case CommandConnections: + go c.handleConnectionsStream() + case CommandOutbounds: + go c.handleOutboundsStream() + default: + return E.New("unknown command: ", command) + } + } + return nil +} + +func (c *CommandClient) Disconnect() error { + c.clientMutex.Lock() + defer c.clientMutex.Unlock() + if c.cancel != nil { + c.cancel() + } + return common.Close(common.PtrOrNil(c.grpcConn)) +} + +func (c *CommandClient) getClientForCall() (daemon.StartedServiceClient, error) { + c.clientMutex.RLock() + if c.grpcClient != nil { + defer c.clientMutex.RUnlock() + return c.grpcClient, nil + } + c.clientMutex.RUnlock() + + c.clientMutex.Lock() + defer c.clientMutex.Unlock() + + if c.grpcClient != nil { + return c.grpcClient, nil + } + + target, contextDialer := dialTarget() + connection, client, err := c.dialWithRetry(target, contextDialer, true) + if err != nil { + return nil, E.Cause(err, "get command client") + } + c.grpcConn = connection + c.grpcClient = client + if c.ctx == nil { + c.ctx, c.cancel = context.WithCancel(context.Background()) + } + return c.grpcClient, nil +} + +func (c *CommandClient) closeConnection() { + c.clientMutex.Lock() + defer c.clientMutex.Unlock() + if c.grpcConn != nil { + c.grpcConn.Close() + c.grpcConn = nil + c.grpcClient = nil + } +} + +func callWithResult[T any](c *CommandClient, call func(client daemon.StartedServiceClient) (T, error)) (T, error) { + client, err := c.getClientForCall() + if err != nil { + var zero T + return zero, err + } + if c.standalone { + defer c.closeConnection() + } + return call(client) +} + +func (c *CommandClient) getStreamContext() (daemon.StartedServiceClient, context.Context) { + c.clientMutex.RLock() + defer c.clientMutex.RUnlock() + return c.grpcClient, c.ctx +} + +func (c *CommandClient) handleLogStream() { + client, ctx := c.getStreamContext() + stream, err := client.SubscribeLog(ctx, &emptypb.Empty{}) + if err != nil { + c.handler.Disconnected(E.Cause(err, "subscribe log").Error()) + return + } + defaultLogLevel, err := client.GetDefaultLogLevel(ctx, &emptypb.Empty{}) + if err != nil { + c.handler.Disconnected(E.Cause(err, "get default log level").Error()) + return + } + c.handler.SetDefaultLogLevel(int32(defaultLogLevel.Level)) + for { + logMessage, err := stream.Recv() + if err != nil { + c.handler.Disconnected(E.Cause(err, "log stream recv").Error()) + return + } + if logMessage.Reset_ { + c.handler.ClearLogs() + } + var messages []*LogEntry + for _, msg := range logMessage.Messages { + messages = append(messages, &LogEntry{ + Level: int32(msg.Level), + Message: msg.Message, + }) + } + c.handler.WriteLogs(newIterator(messages)) + } +} + +func (c *CommandClient) handleStatusStream() { + client, ctx := c.getStreamContext() + interval := c.options.StatusInterval + + stream, err := client.SubscribeStatus(ctx, &daemon.SubscribeStatusRequest{ + Interval: interval, + }) + if err != nil { + c.handler.Disconnected(E.Cause(err, "subscribe status").Error()) + return + } + + for { + status, err := stream.Recv() + if err != nil { + c.handler.Disconnected(E.Cause(err, "status stream recv").Error()) + return + } + c.handler.WriteStatus(statusMessageFromGRPC(status)) + } +} + +func (c *CommandClient) handleGroupStream() { + client, ctx := c.getStreamContext() + + stream, err := client.SubscribeGroups(ctx, &emptypb.Empty{}) + if err != nil { + c.handler.Disconnected(E.Cause(err, "subscribe groups").Error()) + return + } + + for { + groups, err := stream.Recv() + if err != nil { + c.handler.Disconnected(E.Cause(err, "groups stream recv").Error()) + return + } + c.handler.WriteGroups(outboundGroupIteratorFromGRPC(groups)) + } +} + +func (c *CommandClient) handleClashModeStream() { + client, ctx := c.getStreamContext() + + modeStatus, err := client.GetClashModeStatus(ctx, &emptypb.Empty{}) + if err != nil { + c.handler.Disconnected(E.Cause(err, "get clash mode status").Error()) + return + } + + if sFixAndroidStack { + go func() { + c.handler.InitializeClashMode(newIterator(modeStatus.ModeList), modeStatus.CurrentMode) + if len(modeStatus.ModeList) == 0 { + c.handler.Disconnected(E.Cause(os.ErrInvalid, "empty clash mode list").Error()) + } + }() + } else { + c.handler.InitializeClashMode(newIterator(modeStatus.ModeList), modeStatus.CurrentMode) + if len(modeStatus.ModeList) == 0 { + c.handler.Disconnected(E.Cause(os.ErrInvalid, "empty clash mode list").Error()) + return + } + } + + if len(modeStatus.ModeList) == 0 { + return + } + + stream, err := client.SubscribeClashMode(ctx, &emptypb.Empty{}) + if err != nil { + c.handler.Disconnected(E.Cause(err, "subscribe clash mode").Error()) + return + } + + for { + mode, err := stream.Recv() + if err != nil { + c.handler.Disconnected(E.Cause(err, "clash mode stream recv").Error()) + return + } + c.handler.UpdateClashMode(mode.Mode) + } +} + +func (c *CommandClient) handleConnectionsStream() { + client, ctx := c.getStreamContext() + interval := c.options.StatusInterval + + stream, err := client.SubscribeConnections(ctx, &daemon.SubscribeConnectionsRequest{ + Interval: interval, + }) + if err != nil { + c.handler.Disconnected(E.Cause(err, "subscribe connections").Error()) + return + } + + for { + events, err := stream.Recv() + if err != nil { + c.handler.Disconnected(E.Cause(err, "connections stream recv").Error()) + return + } + libboxEvents := connectionEventsFromGRPC(events) + c.handler.WriteConnectionEvents(libboxEvents) + } +} + +func (c *CommandClient) handleOutboundsStream() { + client, ctx := c.getStreamContext() + + stream, err := client.SubscribeOutbounds(ctx, &emptypb.Empty{}) + if err != nil { + c.handler.Disconnected(E.Cause(err, "subscribe outbounds").Error()) + return + } + + for { + list, err := stream.Recv() + if err != nil { + c.handler.Disconnected(E.Cause(err, "outbounds stream recv").Error()) + return + } + c.handler.WriteOutbounds(outboundGroupItemListFromGRPC(list)) + } +} + +func (c *CommandClient) SelectOutbound(groupTag string, outboundTag string) error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SelectOutbound(context.Background(), &daemon.SelectOutboundRequest{ + GroupTag: groupTag, + OutboundTag: outboundTag, + }) + }) + if err != nil { + return E.Cause(err, "select outbound") + } + return nil +} + +func (c *CommandClient) URLTest(groupTag string) error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.URLTest(context.Background(), &daemon.URLTestRequest{ + OutboundTag: groupTag, + }) + }) + if err != nil { + return E.Cause(err, "url test") + } + return nil +} + +func (c *CommandClient) SetClashMode(newMode string) error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SetClashMode(context.Background(), &daemon.ClashMode{ + Mode: newMode, + }) + }) + if err != nil { + return E.Cause(err, "set clash mode") + } + return nil +} + +func (c *CommandClient) CloseConnection(connId string) error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.CloseConnection(context.Background(), &daemon.CloseConnectionRequest{ + Id: connId, + }) + }) + if err != nil { + return E.Cause(err, "close connection") + } + return nil +} + +func (c *CommandClient) CloseConnections() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.CloseAllConnections(context.Background(), &emptypb.Empty{}) + }) + if err != nil { + return E.Cause(err, "close all connections") + } + return nil +} + +func (c *CommandClient) ServiceReload() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.ReloadService(context.Background(), &emptypb.Empty{}) + }) + if err != nil { + return E.Cause(err, "reload service") + } + return nil +} + +func (c *CommandClient) ServiceClose() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.StopService(context.Background(), &emptypb.Empty{}) + }) + if err != nil { + return E.Cause(err, "stop service") + } + return nil +} + +func (c *CommandClient) ClearLogs() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.ClearLogs(context.Background(), &emptypb.Empty{}) + }) + if err != nil { + return E.Cause(err, "clear logs") + } + return nil +} + +func (c *CommandClient) GetSystemProxyStatus() (*SystemProxyStatus, error) { + return callWithResult(c, func(client daemon.StartedServiceClient) (*SystemProxyStatus, error) { + status, err := client.GetSystemProxyStatus(context.Background(), &emptypb.Empty{}) + if err != nil { + return nil, E.Cause(err, "get system proxy status") + } + return systemProxyStatusFromGRPC(status), nil + }) +} + +func (c *CommandClient) SetSystemProxyEnabled(isEnabled bool) error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SetSystemProxyEnabled(context.Background(), &daemon.SetSystemProxyEnabledRequest{ + Enabled: isEnabled, + }) + }) + if err != nil { + return E.Cause(err, "set system proxy enabled") + } + return nil +} + +func (c *CommandClient) TriggerGoCrash() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.TriggerDebugCrash(context.Background(), &daemon.DebugCrashRequest{ + Type: daemon.DebugCrashRequest_GO, + }) + }) + if err != nil { + return E.Cause(err, "trigger debug crash") + } + return nil +} + +func (c *CommandClient) TriggerNativeCrash() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.TriggerDebugCrash(context.Background(), &daemon.DebugCrashRequest{ + Type: daemon.DebugCrashRequest_NATIVE, + }) + }) + if err != nil { + return E.Cause(err, "trigger native crash") + } + return nil +} + +func (c *CommandClient) TriggerOOMReport() error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.TriggerOOMReport(context.Background(), &emptypb.Empty{}) + }) + if err != nil { + return E.Cause(err, "trigger oom report") + } + return nil +} + +func (c *CommandClient) GetDeprecatedNotes() (DeprecatedNoteIterator, error) { + return callWithResult(c, func(client daemon.StartedServiceClient) (DeprecatedNoteIterator, error) { + warnings, err := client.GetDeprecatedWarnings(context.Background(), &emptypb.Empty{}) + if err != nil { + return nil, E.Cause(err, "get deprecated warnings") + } + var notes []*DeprecatedNote + for _, warning := range warnings.Warnings { + notes = append(notes, &DeprecatedNote{ + Description: warning.Description, + DeprecatedVersion: warning.DeprecatedVersion, + ScheduledVersion: warning.ScheduledVersion, + MigrationLink: warning.MigrationLink, + }) + } + return newIterator(notes), nil + }) +} + +func (c *CommandClient) GetStartedAt() (int64, error) { + return callWithResult(c, func(client daemon.StartedServiceClient) (int64, error) { + startedAt, err := client.GetStartedAt(context.Background(), &emptypb.Empty{}) + if err != nil { + return 0, E.Cause(err, "get started at") + } + return startedAt.StartedAt, nil + }) +} + +func (c *CommandClient) SetGroupExpand(groupTag string, isExpand bool) error { + _, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SetGroupExpand(context.Background(), &daemon.SetGroupExpandRequest{ + GroupTag: groupTag, + IsExpand: isExpand, + }) + }) + if err != nil { + return E.Cause(err, "set group expand") + } + return nil +} + +func (c *CommandClient) StartNetworkQualityTest(configURL string, outboundTag string, serial bool, maxRuntimeSeconds int32, http3 bool, handler NetworkQualityTestHandler) error { + client, err := c.getClientForCall() + if err != nil { + return E.Cause(err, "start network quality test") + } + if c.standalone { + defer c.closeConnection() + } + stream, err := client.StartNetworkQualityTest(context.Background(), &daemon.NetworkQualityTestRequest{ + ConfigURL: configURL, + OutboundTag: outboundTag, + Serial: serial, + MaxRuntimeSeconds: maxRuntimeSeconds, + Http3: http3, + }) + if err != nil { + return E.Cause(err, "start network quality test") + } + for { + event, recvErr := stream.Recv() + if recvErr != nil { + recvErr = E.Cause(recvErr, "network quality test recv") + handler.OnError(recvErr.Error()) + return recvErr + } + if event.IsFinal { + if event.Error != "" { + handler.OnError(event.Error) + } else { + handler.OnResult(&NetworkQualityResult{ + DownloadCapacity: event.DownloadCapacity, + UploadCapacity: event.UploadCapacity, + DownloadRPM: event.DownloadRPM, + UploadRPM: event.UploadRPM, + IdleLatencyMs: event.IdleLatencyMs, + DownloadCapacityAccuracy: event.DownloadCapacityAccuracy, + UploadCapacityAccuracy: event.UploadCapacityAccuracy, + DownloadRPMAccuracy: event.DownloadRPMAccuracy, + UploadRPMAccuracy: event.UploadRPMAccuracy, + }) + } + return nil + } + handler.OnProgress(networkQualityProgressFromGRPC(event)) + } +} + +func (c *CommandClient) StartSTUNTest(server string, outboundTag string, handler STUNTestHandler) error { + client, err := c.getClientForCall() + if err != nil { + return E.Cause(err, "start stun test") + } + if c.standalone { + defer c.closeConnection() + } + stream, err := client.StartSTUNTest(context.Background(), &daemon.STUNTestRequest{ + Server: server, + OutboundTag: outboundTag, + }) + if err != nil { + return E.Cause(err, "start stun test") + } + for { + event, recvErr := stream.Recv() + if recvErr != nil { + recvErr = E.Cause(recvErr, "stun test recv") + handler.OnError(recvErr.Error()) + return recvErr + } + if event.IsFinal { + if event.Error != "" { + handler.OnError(event.Error) + } else { + handler.OnResult(&STUNTestResult{ + ExternalAddr: event.ExternalAddr, + LatencyMs: event.LatencyMs, + NATMapping: event.NatMapping, + NATFiltering: event.NatFiltering, + NATTypeSupported: event.NatTypeSupported, + }) + } + return nil + } + handler.OnProgress(stunTestProgressFromGRPC(event)) + } +} + +func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) error { + client, err := c.getClientForCall() + if err != nil { + return E.Cause(err, "subscribe tailscale status") + } + if c.standalone { + defer c.closeConnection() + } + stream, err := client.SubscribeTailscaleStatus(context.Background(), &emptypb.Empty{}) + if err != nil { + return E.Cause(err, "subscribe tailscale status") + } + for { + event, recvErr := stream.Recv() + if recvErr != nil { + if status.Code(recvErr) == codes.NotFound || status.Code(recvErr) == codes.Unavailable { + return nil + } + recvErr = E.Cause(recvErr, "tailscale status recv") + handler.OnError(recvErr.Error()) + return recvErr + } + handler.OnStatusUpdate(tailscaleStatusUpdateFromGRPC(event)) + } +} + +func (c *CommandClient) StartTailscalePing(endpointTag string, peerIP string, handler TailscalePingHandler) error { + client, err := c.getClientForCall() + if err != nil { + return E.Cause(err, "start tailscale ping") + } + if c.standalone { + defer c.closeConnection() + } + stream, err := client.StartTailscalePing(context.Background(), &daemon.TailscalePingRequest{ + EndpointTag: endpointTag, + PeerIP: peerIP, + }) + if err != nil { + return E.Cause(err, "start tailscale ping") + } + for { + event, recvErr := stream.Recv() + if recvErr != nil { + recvErr = E.Cause(recvErr, "tailscale ping recv") + handler.OnError(recvErr.Error()) + return recvErr + } + handler.OnPingResult(tailscalePingResultFromGRPC(event)) + } +} diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go new file mode 100644 index 00000000..60ec17a8 --- /dev/null +++ b/experimental/libbox/command_server.go @@ -0,0 +1,288 @@ +package libbox + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "strconv" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +type CommandServer struct { + *daemon.StartedService + handler CommandServerHandler + platformInterface PlatformInterface + platformWrapper *platformInterfaceWrapper + grpcServer *grpc.Server + listener net.Listener + endPauseTimer *time.Timer +} + +type CommandServerHandler interface { + ServiceStop() error + ServiceReload() error + GetSystemProxyStatus() (*SystemProxyStatus, error) + SetSystemProxyEnabled(enabled bool) error + TriggerNativeCrash() error + WriteDebugMessage(message string) +} + +func NewCommandServer(handler CommandServerHandler, platformInterface PlatformInterface) (*CommandServer, error) { + ctx := baseContext(platformInterface) + platformWrapper := &platformInterfaceWrapper{ + iif: platformInterface, + useProcFS: platformInterface.UseProcFS(), + } + service.MustRegister[adapter.PlatformInterface](ctx, platformWrapper) + server := &CommandServer{ + handler: handler, + platformInterface: platformInterface, + platformWrapper: platformWrapper, + } + server.StartedService = daemon.NewStartedService(daemon.ServiceOptions{ + Context: ctx, + // Platform: platformWrapper, + Handler: (*platformHandler)(server), + Debug: sDebug, + LogMaxLines: sLogMaxLines, + OOMKillerEnabled: sOOMKillerEnabled, + OOMKillerDisabled: sOOMKillerDisabled, + OOMMemoryLimit: uint64(sOOMMemoryLimit), + // WorkingDirectory: sWorkingPath, + // TempDirectory: sTempPath, + // UserID: sUserID, + // GroupID: sGroupID, + // SystemProxyEnabled: false, + }) + return server, nil +} + +func unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if sCommandServerSecret == "" { + return handler(ctx, req) + } + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Error(codes.Unauthenticated, "missing metadata") + } + values := md.Get("x-command-secret") + if len(values) == 0 { + return nil, status.Error(codes.Unauthenticated, "missing authentication secret") + } + if values[0] != sCommandServerSecret { + return nil, status.Error(codes.Unauthenticated, "invalid authentication secret") + } + return handler(ctx, req) +} + +func streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if sCommandServerSecret == "" { + return handler(srv, ss) + } + md, ok := metadata.FromIncomingContext(ss.Context()) + if !ok { + return status.Error(codes.Unauthenticated, "missing metadata") + } + values := md.Get("x-command-secret") + if len(values) == 0 { + return status.Error(codes.Unauthenticated, "missing authentication secret") + } + if values[0] != sCommandServerSecret { + return status.Error(codes.Unauthenticated, "invalid authentication secret") + } + return handler(srv, ss) +} + +func (s *CommandServer) Start() error { + var ( + listener net.Listener + err error + ) + if sCommandServerListenPort == 0 { + sockPath := filepath.Join(sBasePath, "command.sock") + os.Remove(sockPath) + for i := 0; i < 30; i++ { + listener, err = net.ListenUnix("unix", &net.UnixAddr{ + Name: sockPath, + Net: "unix", + }) + if err == nil { + break + } + if !errors.Is(err, syscall.EROFS) { + break + } + time.Sleep(time.Second) + } + if err != nil { + return E.Cause(err, "listen command server") + } + if sUserID != os.Getuid() { + err = os.Chown(sockPath, sUserID, sGroupID) + if err != nil { + listener.Close() + os.Remove(sockPath) + return E.Cause(err, "chown") + } + } + } else { + listener, err = net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(sCommandServerListenPort)))) + if err != nil { + return E.Cause(err, "listen command server") + } + } + s.listener = listener + serverOptions := []grpc.ServerOption{ + grpc.UnaryInterceptor(unaryAuthInterceptor), + grpc.StreamInterceptor(streamAuthInterceptor), + } + s.grpcServer = grpc.NewServer(serverOptions...) + daemon.RegisterStartedServiceServer(s.grpcServer, s.StartedService) + go s.grpcServer.Serve(listener) + return nil +} + +func (s *CommandServer) Close() { + if s.grpcServer != nil { + s.grpcServer.Stop() + } + common.Close(s.listener) + s.StartedService.Close() +} + +type OverrideOptions struct { + AutoRedirect bool + IncludePackage StringIterator + ExcludePackage StringIterator +} + +func (s *CommandServer) StartOrReloadService(configContent string, options *OverrideOptions) error { + saveConfigSnapshot(configContent) + err := s.StartedService.StartOrReloadService(configContent, &daemon.OverrideOptions{ + AutoRedirect: options.AutoRedirect, + IncludePackage: iteratorToArray(options.IncludePackage), + ExcludePackage: iteratorToArray(options.ExcludePackage), + }) + if err != nil { + return E.Cause(err, "start or reload service") + } + return nil +} + +func (s *CommandServer) CloseService() error { + return s.StartedService.CloseService() +} + +func (s *CommandServer) WriteMessage(level int32, message string) { + s.StartedService.WriteMessage(log.Level(level), message) +} + +func (s *CommandServer) SetError(message string) { + s.StartedService.SetError(E.New(message)) +} + +func (s *CommandServer) NeedWIFIState() bool { + instance := s.StartedService.Instance() + if instance == nil || instance.Box() == nil { + return false + } + return instance.Box().Network().NeedWIFIState() +} + +func (s *CommandServer) NeedFindProcess() bool { + instance := s.StartedService.Instance() + if instance == nil || instance.Box() == nil { + return false + } + return instance.Box().Router().NeedFindProcess() +} + +func (s *CommandServer) Pause() { + instance := s.StartedService.Instance() + if instance == nil || instance.PauseManager() == nil { + return + } + instance.PauseManager().DevicePause() + if C.IsIos { + if s.endPauseTimer == nil { + s.endPauseTimer = time.AfterFunc(time.Minute, instance.PauseManager().DeviceWake) + } else { + s.endPauseTimer.Reset(time.Minute) + } + } +} + +func (s *CommandServer) Wake() { + instance := s.StartedService.Instance() + if instance == nil || instance.PauseManager() == nil { + return + } + if !C.IsIos { + instance.PauseManager().DeviceWake() + } +} + +func (s *CommandServer) ResetNetwork() { + instance := s.StartedService.Instance() + if instance == nil || instance.Box() == nil { + return + } + instance.Box().Router().ResetNetwork() +} + +func (s *CommandServer) UpdateWIFIState() { + instance := s.StartedService.Instance() + if instance == nil || instance.Box() == nil { + return + } + instance.Box().Network().UpdateWIFIState() +} + +type platformHandler CommandServer + +func (h *platformHandler) ServiceStop() error { + return (*CommandServer)(h).handler.ServiceStop() +} + +func (h *platformHandler) ServiceReload() error { + return (*CommandServer)(h).handler.ServiceReload() +} + +func (h *platformHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + status, err := (*CommandServer)(h).handler.GetSystemProxyStatus() + if err != nil { + return nil, E.Cause(err, "get system proxy status") + } + return &daemon.SystemProxyStatus{ + Enabled: status.Enabled, + Available: status.Available, + }, nil +} + +func (h *platformHandler) SetSystemProxyEnabled(enabled bool) error { + return (*CommandServer)(h).handler.SetSystemProxyEnabled(enabled) +} + +func (h *platformHandler) TriggerNativeCrash() error { + return (*CommandServer)(h).handler.TriggerNativeCrash() +} + +func (h *platformHandler) WriteDebugMessage(message string) { + (*CommandServer)(h).handler.WriteDebugMessage(message) +} diff --git a/experimental/libbox/command_types.go b/experimental/libbox/command_types.go new file mode 100644 index 00000000..61634b01 --- /dev/null +++ b/experimental/libbox/command_types.go @@ -0,0 +1,446 @@ +package libbox + +import ( + "slices" + "strings" + "time" + + "github.com/sagernet/sing-box/daemon" + M "github.com/sagernet/sing/common/metadata" +) + +type StatusMessage struct { + Memory int64 + Goroutines int32 + ConnectionsIn int32 + ConnectionsOut int32 + TrafficAvailable bool + Uplink int64 + Downlink int64 + UplinkTotal int64 + DownlinkTotal int64 +} + +type SystemProxyStatus struct { + Available bool + Enabled bool +} + +type OutboundGroup struct { + Tag string + Type string + Selectable bool + Selected string + IsExpand bool + itemList []*OutboundGroupItem +} + +func (g *OutboundGroup) GetItems() OutboundGroupItemIterator { + return newIterator(g.itemList) +} + +type OutboundGroupIterator interface { + Next() *OutboundGroup + HasNext() bool +} + +type OutboundGroupItem struct { + Tag string + Type string + URLTestTime int64 + URLTestDelay int32 +} + +type OutboundGroupItemIterator interface { + Next() *OutboundGroupItem + HasNext() bool +} + +const ( + ConnectionStateAll = iota + ConnectionStateActive + ConnectionStateClosed +) + +const ( + ConnectionEventNew = iota + ConnectionEventUpdate + ConnectionEventClosed +) + +const ( + closedConnectionMaxAge = int64((5 * time.Minute) / time.Millisecond) +) + +type ConnectionEvent struct { + Type int32 + ID string + Connection *Connection + UplinkDelta int64 + DownlinkDelta int64 + ClosedAt int64 +} + +type ConnectionEvents struct { + Reset bool + events []*ConnectionEvent +} + +func (c *ConnectionEvents) Iterator() ConnectionEventIterator { + return newIterator(c.events) +} + +type ConnectionEventIterator interface { + Next() *ConnectionEvent + HasNext() bool +} + +type Connections struct { + connectionMap map[string]*Connection + input []Connection + filtered []Connection + filterState int32 + filterApplied bool +} + +func NewConnections() *Connections { + return &Connections{ + connectionMap: make(map[string]*Connection), + } +} + +func (c *Connections) ApplyEvents(events *ConnectionEvents) { + if events == nil { + return + } + if events.Reset { + c.connectionMap = make(map[string]*Connection) + } + + for _, event := range events.events { + switch event.Type { + case ConnectionEventNew: + if event.Connection != nil { + conn := *event.Connection + c.connectionMap[event.ID] = &conn + } + case ConnectionEventUpdate: + if conn, ok := c.connectionMap[event.ID]; ok { + conn.Uplink = event.UplinkDelta + conn.Downlink = event.DownlinkDelta + conn.UplinkTotal += event.UplinkDelta + conn.DownlinkTotal += event.DownlinkDelta + } + case ConnectionEventClosed: + if event.Connection != nil { + conn := *event.Connection + conn.ClosedAt = event.ClosedAt + conn.Uplink = 0 + conn.Downlink = 0 + c.connectionMap[event.ID] = &conn + continue + } + if conn, ok := c.connectionMap[event.ID]; ok { + conn.ClosedAt = event.ClosedAt + conn.Uplink = 0 + conn.Downlink = 0 + } + } + } + + c.evictClosedConnections(time.Now().UnixMilli()) + c.input = c.input[:0] + for _, conn := range c.connectionMap { + c.input = append(c.input, *conn) + } + if c.filterApplied { + c.FilterState(c.filterState) + } else { + c.filtered = c.filtered[:0] + c.filtered = append(c.filtered, c.input...) + } +} + +func (c *Connections) evictClosedConnections(nowMilliseconds int64) { + for id, conn := range c.connectionMap { + if conn.ClosedAt == 0 { + continue + } + if nowMilliseconds-conn.ClosedAt > closedConnectionMaxAge { + delete(c.connectionMap, id) + } + } +} + +func (c *Connections) FilterState(state int32) { + c.filterApplied = true + c.filterState = state + c.filtered = c.filtered[:0] + switch state { + case ConnectionStateAll: + c.filtered = append(c.filtered, c.input...) + case ConnectionStateActive: + for _, connection := range c.input { + if connection.ClosedAt == 0 { + c.filtered = append(c.filtered, connection) + } + } + case ConnectionStateClosed: + for _, connection := range c.input { + if connection.ClosedAt != 0 { + c.filtered = append(c.filtered, connection) + } + } + } +} + +func (c *Connections) SortByDate() { + slices.SortStableFunc(c.filtered, func(x, y Connection) int { + if x.CreatedAt < y.CreatedAt { + return 1 + } else if x.CreatedAt > y.CreatedAt { + return -1 + } else { + return strings.Compare(y.ID, x.ID) + } + }) +} + +func (c *Connections) SortByTraffic() { + slices.SortStableFunc(c.filtered, func(x, y Connection) int { + xTraffic := x.Uplink + x.Downlink + yTraffic := y.Uplink + y.Downlink + if xTraffic < yTraffic { + return 1 + } else if xTraffic > yTraffic { + return -1 + } else { + return strings.Compare(y.ID, x.ID) + } + }) +} + +func (c *Connections) SortByTrafficTotal() { + slices.SortStableFunc(c.filtered, func(x, y Connection) int { + xTraffic := x.UplinkTotal + x.DownlinkTotal + yTraffic := y.UplinkTotal + y.DownlinkTotal + if xTraffic < yTraffic { + return 1 + } else if xTraffic > yTraffic { + return -1 + } else { + return strings.Compare(y.ID, x.ID) + } + }) +} + +func (c *Connections) Iterator() ConnectionIterator { + return newPtrIterator(c.filtered) +} + +type ProcessInfo struct { + ProcessID int64 + UserID int32 + UserName string + ProcessPath string + packageNames []string +} + +func (p *ProcessInfo) PackageNames() StringIterator { + return newIterator(p.packageNames) +} + +type Connection struct { + ID string + Inbound string + InboundType string + IPVersion int32 + Network string + Source string + Destination string + Domain string + Protocol string + User string + FromOutbound string + CreatedAt int64 + ClosedAt int64 + Uplink int64 + Downlink int64 + UplinkTotal int64 + DownlinkTotal int64 + Rule string + Outbound string + OutboundType string + chainList []string + ProcessInfo *ProcessInfo +} + +func (c *Connection) Chain() StringIterator { + return newIterator(c.chainList) +} + +func (c *Connection) DisplayDestination() string { + destination := M.ParseSocksaddr(c.Destination) + if destination.IsIP() && c.Domain != "" { + destination = M.Socksaddr{ + Fqdn: c.Domain, + Port: destination.Port, + } + return destination.String() + } + return c.Destination +} + +type ConnectionIterator interface { + Next() *Connection + HasNext() bool +} + +func statusMessageFromGRPC(status *daemon.Status) *StatusMessage { + if status == nil { + return nil + } + return &StatusMessage{ + Memory: int64(status.Memory), + Goroutines: status.Goroutines, + ConnectionsIn: status.ConnectionsIn, + ConnectionsOut: status.ConnectionsOut, + TrafficAvailable: status.TrafficAvailable, + Uplink: status.Uplink, + Downlink: status.Downlink, + UplinkTotal: status.UplinkTotal, + DownlinkTotal: status.DownlinkTotal, + } +} + +func outboundGroupIteratorFromGRPC(groups *daemon.Groups) OutboundGroupIterator { + if groups == nil || len(groups.Group) == 0 { + return newIterator([]*OutboundGroup{}) + } + var libboxGroups []*OutboundGroup + for _, g := range groups.Group { + libboxGroup := &OutboundGroup{ + Tag: g.Tag, + Type: g.Type, + Selectable: g.Selectable, + Selected: g.Selected, + IsExpand: g.IsExpand, + } + for _, item := range g.Items { + libboxGroup.itemList = append(libboxGroup.itemList, &OutboundGroupItem{ + Tag: item.Tag, + Type: item.Type, + URLTestTime: item.UrlTestTime, + URLTestDelay: item.UrlTestDelay, + }) + } + libboxGroups = append(libboxGroups, libboxGroup) + } + return newIterator(libboxGroups) +} + +func outboundGroupItemListFromGRPC(list *daemon.OutboundList) OutboundGroupItemIterator { + if list == nil || len(list.Outbounds) == 0 { + return newIterator([]*OutboundGroupItem{}) + } + var items []*OutboundGroupItem + for _, ob := range list.Outbounds { + items = append(items, &OutboundGroupItem{ + Tag: ob.Tag, + Type: ob.Type, + URLTestTime: ob.UrlTestTime, + URLTestDelay: ob.UrlTestDelay, + }) + } + return newIterator(items) +} + +func connectionFromGRPC(conn *daemon.Connection) Connection { + var processInfo *ProcessInfo + if conn.ProcessInfo != nil { + processInfo = &ProcessInfo{ + ProcessID: int64(conn.ProcessInfo.ProcessId), + UserID: conn.ProcessInfo.UserId, + UserName: conn.ProcessInfo.UserName, + ProcessPath: conn.ProcessInfo.ProcessPath, + packageNames: conn.ProcessInfo.PackageNames, + } + } + return Connection{ + ID: conn.Id, + Inbound: conn.Inbound, + InboundType: conn.InboundType, + IPVersion: conn.IpVersion, + Network: conn.Network, + Source: conn.Source, + Destination: conn.Destination, + Domain: conn.Domain, + Protocol: conn.Protocol, + User: conn.User, + FromOutbound: conn.FromOutbound, + CreatedAt: conn.CreatedAt, + ClosedAt: conn.ClosedAt, + Uplink: conn.Uplink, + Downlink: conn.Downlink, + UplinkTotal: conn.UplinkTotal, + DownlinkTotal: conn.DownlinkTotal, + Rule: conn.Rule, + Outbound: conn.Outbound, + OutboundType: conn.OutboundType, + chainList: conn.ChainList, + ProcessInfo: processInfo, + } +} + +func connectionEventFromGRPC(event *daemon.ConnectionEvent) *ConnectionEvent { + if event == nil { + return nil + } + libboxEvent := &ConnectionEvent{ + Type: int32(event.Type), + ID: event.Id, + UplinkDelta: event.UplinkDelta, + DownlinkDelta: event.DownlinkDelta, + ClosedAt: event.ClosedAt, + } + if event.Connection != nil { + conn := connectionFromGRPC(event.Connection) + libboxEvent.Connection = &conn + } + return libboxEvent +} + +func connectionEventsFromGRPC(events *daemon.ConnectionEvents) *ConnectionEvents { + if events == nil { + return nil + } + libboxEvents := &ConnectionEvents{ + Reset: events.Reset_, + } + for _, event := range events.Events { + if libboxEvent := connectionEventFromGRPC(event); libboxEvent != nil { + libboxEvents.events = append(libboxEvents.events, libboxEvent) + } + } + return libboxEvents +} + +func systemProxyStatusFromGRPC(status *daemon.SystemProxyStatus) *SystemProxyStatus { + if status == nil { + return nil + } + return &SystemProxyStatus{ + Available: status.Available, + Enabled: status.Enabled, + } +} + +func systemProxyStatusToGRPC(status *SystemProxyStatus) *daemon.SystemProxyStatus { + if status == nil { + return nil + } + return &daemon.SystemProxyStatus{ + Available: status.Available, + Enabled: status.Enabled, + } +} diff --git a/experimental/libbox/command_types_nq.go b/experimental/libbox/command_types_nq.go new file mode 100644 index 00000000..fc8957e2 --- /dev/null +++ b/experimental/libbox/command_types_nq.go @@ -0,0 +1,51 @@ +package libbox + +import "github.com/sagernet/sing-box/daemon" + +type NetworkQualityProgress struct { + Phase int32 + DownloadCapacity int64 + UploadCapacity int64 + DownloadRPM int32 + UploadRPM int32 + IdleLatencyMs int32 + ElapsedMs int64 + DownloadCapacityAccuracy int32 + UploadCapacityAccuracy int32 + DownloadRPMAccuracy int32 + UploadRPMAccuracy int32 +} + +type NetworkQualityResult struct { + DownloadCapacity int64 + UploadCapacity int64 + DownloadRPM int32 + UploadRPM int32 + IdleLatencyMs int32 + DownloadCapacityAccuracy int32 + UploadCapacityAccuracy int32 + DownloadRPMAccuracy int32 + UploadRPMAccuracy int32 +} + +type NetworkQualityTestHandler interface { + OnProgress(progress *NetworkQualityProgress) + OnResult(result *NetworkQualityResult) + OnError(message string) +} + +func networkQualityProgressFromGRPC(event *daemon.NetworkQualityTestProgress) *NetworkQualityProgress { + return &NetworkQualityProgress{ + Phase: event.Phase, + DownloadCapacity: event.DownloadCapacity, + UploadCapacity: event.UploadCapacity, + DownloadRPM: event.DownloadRPM, + UploadRPM: event.UploadRPM, + IdleLatencyMs: event.IdleLatencyMs, + ElapsedMs: event.ElapsedMs, + DownloadCapacityAccuracy: event.DownloadCapacityAccuracy, + UploadCapacityAccuracy: event.UploadCapacityAccuracy, + DownloadRPMAccuracy: event.DownloadRPMAccuracy, + UploadRPMAccuracy: event.UploadRPMAccuracy, + } +} diff --git a/experimental/libbox/command_types_stun.go b/experimental/libbox/command_types_stun.go new file mode 100644 index 00000000..22846c32 --- /dev/null +++ b/experimental/libbox/command_types_stun.go @@ -0,0 +1,35 @@ +package libbox + +import "github.com/sagernet/sing-box/daemon" + +type STUNTestProgress struct { + Phase int32 + ExternalAddr string + LatencyMs int32 + NATMapping int32 + NATFiltering int32 +} + +type STUNTestResult struct { + ExternalAddr string + LatencyMs int32 + NATMapping int32 + NATFiltering int32 + NATTypeSupported bool +} + +type STUNTestHandler interface { + OnProgress(progress *STUNTestProgress) + OnResult(result *STUNTestResult) + OnError(message string) +} + +func stunTestProgressFromGRPC(event *daemon.STUNTestProgress) *STUNTestProgress { + return &STUNTestProgress{ + Phase: event.Phase, + ExternalAddr: event.ExternalAddr, + LatencyMs: event.LatencyMs, + NATMapping: event.NatMapping, + NATFiltering: event.NatFiltering, + } +} diff --git a/experimental/libbox/command_types_tailscale.go b/experimental/libbox/command_types_tailscale.go new file mode 100644 index 00000000..dc17639d --- /dev/null +++ b/experimental/libbox/command_types_tailscale.go @@ -0,0 +1,132 @@ +package libbox + +import "github.com/sagernet/sing-box/daemon" + +type TailscaleStatusUpdate struct { + endpoints []*TailscaleEndpointStatus +} + +func (u *TailscaleStatusUpdate) Endpoints() TailscaleEndpointStatusIterator { + return newIterator(u.endpoints) +} + +type TailscaleEndpointStatusIterator interface { + Next() *TailscaleEndpointStatus + HasNext() bool +} + +type TailscaleEndpointStatus struct { + EndpointTag string + BackendState string + AuthURL string + NetworkName string + MagicDNSSuffix string + Self *TailscalePeer + userGroups []*TailscaleUserGroup +} + +func (s *TailscaleEndpointStatus) UserGroups() TailscaleUserGroupIterator { + return newIterator(s.userGroups) +} + +type TailscaleUserGroupIterator interface { + Next() *TailscaleUserGroup + HasNext() bool +} + +type TailscaleUserGroup struct { + UserID int64 + LoginName string + DisplayName string + ProfilePicURL string + peers []*TailscalePeer +} + +func (g *TailscaleUserGroup) Peers() TailscalePeerIterator { + return newIterator(g.peers) +} + +type TailscalePeerIterator interface { + Next() *TailscalePeer + HasNext() bool +} + +type TailscalePeer struct { + HostName string + DNSName string + OS string + tailscaleIPs []string + Online bool + ExitNode bool + ExitNodeOption bool + Active bool + RxBytes int64 + TxBytes int64 + KeyExpiry int64 +} + +func (p *TailscalePeer) TailscaleIPs() StringIterator { + return newIterator(p.tailscaleIPs) +} + +type TailscaleStatusHandler interface { + OnStatusUpdate(status *TailscaleStatusUpdate) + OnError(message string) +} + +func tailscaleStatusUpdateFromGRPC(update *daemon.TailscaleStatusUpdate) *TailscaleStatusUpdate { + endpoints := make([]*TailscaleEndpointStatus, len(update.Endpoints)) + for i, endpoint := range update.Endpoints { + endpoints[i] = tailscaleEndpointStatusFromGRPC(endpoint) + } + return &TailscaleStatusUpdate{endpoints: endpoints} +} + +func tailscaleEndpointStatusFromGRPC(status *daemon.TailscaleEndpointStatus) *TailscaleEndpointStatus { + userGroups := make([]*TailscaleUserGroup, len(status.UserGroups)) + for i, group := range status.UserGroups { + userGroups[i] = tailscaleUserGroupFromGRPC(group) + } + result := &TailscaleEndpointStatus{ + EndpointTag: status.EndpointTag, + BackendState: status.BackendState, + AuthURL: status.AuthURL, + NetworkName: status.NetworkName, + MagicDNSSuffix: status.MagicDNSSuffix, + userGroups: userGroups, + } + if status.Self != nil { + result.Self = tailscalePeerFromGRPC(status.Self) + } + return result +} + +func tailscaleUserGroupFromGRPC(group *daemon.TailscaleUserGroup) *TailscaleUserGroup { + peers := make([]*TailscalePeer, len(group.Peers)) + for i, peer := range group.Peers { + peers[i] = tailscalePeerFromGRPC(peer) + } + return &TailscaleUserGroup{ + UserID: group.UserID, + LoginName: group.LoginName, + DisplayName: group.DisplayName, + ProfilePicURL: group.ProfilePicURL, + peers: peers, + } +} + +func tailscalePeerFromGRPC(peer *daemon.TailscalePeer) *TailscalePeer { + return &TailscalePeer{ + HostName: peer.HostName, + DNSName: peer.DnsName, + OS: peer.Os, + tailscaleIPs: peer.TailscaleIPs, + Online: peer.Online, + ExitNode: peer.ExitNode, + ExitNodeOption: peer.ExitNodeOption, + Active: peer.Active, + RxBytes: peer.RxBytes, + TxBytes: peer.TxBytes, + KeyExpiry: peer.KeyExpiry, + } +} diff --git a/experimental/libbox/command_types_tailscale_ping.go b/experimental/libbox/command_types_tailscale_ping.go new file mode 100644 index 00000000..666789d0 --- /dev/null +++ b/experimental/libbox/command_types_tailscale_ping.go @@ -0,0 +1,28 @@ +package libbox + +import "github.com/sagernet/sing-box/daemon" + +type TailscalePingResult struct { + LatencyMs float64 + IsDirect bool + Endpoint string + DERPRegionID int32 + DERPRegionCode string + Error string +} + +type TailscalePingHandler interface { + OnPingResult(result *TailscalePingResult) + OnError(message string) +} + +func tailscalePingResultFromGRPC(response *daemon.TailscalePingResponse) *TailscalePingResult { + return &TailscalePingResult{ + LatencyMs: response.LatencyMs, + IsDirect: response.IsDirect, + Endpoint: response.Endpoint, + DERPRegionID: response.DerpRegionID, + DERPRegionCode: response.DerpRegionCode, + Error: response.Error, + } +} diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go new file mode 100644 index 00000000..4b21e505 --- /dev/null +++ b/experimental/libbox/config.go @@ -0,0 +1,222 @@ +package libbox + +import ( + "bytes" + "context" + "os" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/oomkiller" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +var sOOMReporter oomkiller.OOMReporter + +func baseContext(platformInterface PlatformInterface) context.Context { + dnsRegistry := include.DNSTransportRegistry() + if platformInterface != nil { + if localTransport := platformInterface.LocalDNSTransport(); localTransport != nil { + dns.RegisterTransport[option.LocalDNSServerOptions](dnsRegistry, C.DNSTypeLocal, func(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { + return newPlatformTransport(localTransport, tag, options), nil + }) + } + } + ctx := context.Background() + ctx = filemanager.WithDefault(ctx, sWorkingPath, sTempPath, sUserID, sGroupID) + if sOOMReporter != nil { + ctx = service.ContextWith[oomkiller.OOMReporter](ctx, sOOMReporter) + } + return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry(), include.CertificateProviderRegistry()) +} + +func parseConfig(ctx context.Context, configContent string) (option.Options, error) { + options, err := json.UnmarshalExtendedContext[option.Options](ctx, []byte(configContent)) + if err != nil { + return option.Options{}, E.Cause(err, "decode config") + } + return options, nil +} + +func CheckConfig(configContent string) error { + ctx := baseContext(nil) + options, err := parseConfig(ctx, configContent) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + ctx = service.ContextWith[adapter.PlatformInterface](ctx, (*platformInterfaceStub)(nil)) + instance, err := box.New(box.Options{ + Context: ctx, + Options: options, + }) + if err == nil { + instance.Close() + } + return err +} + +type platformInterfaceStub struct{} + +func (s *platformInterfaceStub) Initialize(networkManager adapter.NetworkManager) error { + return nil +} + +func (s *platformInterfaceStub) UsePlatformAutoDetectInterfaceControl() bool { + return true +} + +func (s *platformInterfaceStub) AutoDetectInterfaceControl(fd int) error { + return nil +} + +func (s *platformInterfaceStub) UsePlatformInterface() bool { + return false +} + +func (s *platformInterfaceStub) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { + return nil, os.ErrInvalid +} + +func (s *platformInterfaceStub) UsePlatformDefaultInterfaceMonitor() bool { + return true +} + +func (s *platformInterfaceStub) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor { + return (*interfaceMonitorStub)(nil) +} + +func (s *platformInterfaceStub) UsePlatformNetworkInterfaces() bool { + return false +} + +func (s *platformInterfaceStub) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, os.ErrInvalid +} + +func (s *platformInterfaceStub) UnderNetworkExtension() bool { + return false +} + +func (s *platformInterfaceStub) NetworkExtensionIncludeAllNetworks() bool { + return false +} + +func (s *platformInterfaceStub) ClearDNSCache() { +} + +func (s *platformInterfaceStub) RequestPermissionForWIFIState() error { + return nil +} + +func (s *platformInterfaceStub) UsePlatformWIFIMonitor() bool { + return false +} + +func (s *platformInterfaceStub) ReadWIFIState() adapter.WIFIState { + return adapter.WIFIState{} +} + +func (s *platformInterfaceStub) SystemCertificates() []string { + return nil +} + +func (s *platformInterfaceStub) UsePlatformConnectionOwnerFinder() bool { + return false +} + +func (s *platformInterfaceStub) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + return nil, os.ErrInvalid +} + +func (s *platformInterfaceStub) UsePlatformNotification() bool { + return false +} + +func (s *platformInterfaceStub) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (s *platformInterfaceStub) UsePlatformNeighborResolver() bool { + return false +} + +func (s *platformInterfaceStub) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (s *platformInterfaceStub) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool { + return false +} + +func (s *platformInterfaceStub) LocalDNSTransport() dns.TransportConstructorFunc[option.LocalDNSServerOptions] { + return nil +} + +type interfaceMonitorStub struct{} + +func (s *interfaceMonitorStub) Start() error { + return os.ErrInvalid +} + +func (s *interfaceMonitorStub) Close() error { + return os.ErrInvalid +} + +func (s *interfaceMonitorStub) DefaultInterface() *control.Interface { + return nil +} + +func (s *interfaceMonitorStub) OverrideAndroidVPN() bool { + return false +} + +func (s *interfaceMonitorStub) AndroidVPNEnabled() bool { + return false +} + +func (s *interfaceMonitorStub) RegisterCallback(callback tun.DefaultInterfaceUpdateCallback) *list.Element[tun.DefaultInterfaceUpdateCallback] { + return nil +} + +func (s *interfaceMonitorStub) UnregisterCallback(element *list.Element[tun.DefaultInterfaceUpdateCallback]) { +} + +func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { +} + +func (s *interfaceMonitorStub) MyInterface() string { + return "" +} + +func FormatConfig(configContent string) (*StringBox, error) { + options, err := parseConfig(baseContext(nil), configContent) + if err != nil { + return nil, err + } + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(options) + if err != nil { + return nil, err + } + return wrapString(buffer.String()), nil +} diff --git a/experimental/libbox/connection_owner_darwin.go b/experimental/libbox/connection_owner_darwin.go new file mode 100644 index 00000000..20220106 --- /dev/null +++ b/experimental/libbox/connection_owner_darwin.go @@ -0,0 +1,57 @@ +package libbox + +import ( + "net/netip" + "os/user" + "syscall" + + "github.com/sagernet/sing-box/common/process" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +func FindConnectionOwner(ipProtocol int32, sourceAddress string, sourcePort int32, destinationAddress string, destinationPort int32) (*ConnectionOwner, error) { + source, err := parseConnectionOwnerAddrPort(sourceAddress, sourcePort) + if err != nil { + return nil, E.Cause(err, "parse source") + } + destination, err := parseConnectionOwnerAddrPort(destinationAddress, destinationPort) + if err != nil { + return nil, E.Cause(err, "parse destination") + } + var network string + switch ipProtocol { + case syscall.IPPROTO_TCP: + network = "tcp" + case syscall.IPPROTO_UDP: + network = "udp" + default: + return nil, E.New("unknown protocol: ", ipProtocol) + } + owner, err := process.FindDarwinConnectionOwner(network, source, destination) + if err != nil { + return nil, err + } + result := &ConnectionOwner{ + UserId: owner.UserId, + ProcessPath: owner.ProcessPath, + } + if owner.UserId != -1 && owner.UserName == "" { + osUser, _ := user.LookupId(F.ToString(owner.UserId)) + if osUser != nil { + result.UserName = osUser.Username + } + } + return result, nil +} + +func parseConnectionOwnerAddrPort(address string, port int32) (netip.AddrPort, error) { + if port < 0 || port > 65535 { + return netip.AddrPort{}, E.New("invalid port: ", port) + } + addr, err := netip.ParseAddr(address) + if err != nil { + return netip.AddrPort{}, err + } + return netip.AddrPortFrom(addr.Unmap(), uint16(port)), nil +} diff --git a/experimental/libbox/debug.go b/experimental/libbox/debug.go new file mode 100644 index 00000000..75942976 --- /dev/null +++ b/experimental/libbox/debug.go @@ -0,0 +1,12 @@ +package libbox + +import ( + "time" + "unsafe" +) + +func TriggerGoPanic() { + time.AfterFunc(200*time.Millisecond, func() { + *(*int)(unsafe.Pointer(uintptr(0))) = 0 + }) +} diff --git a/experimental/libbox/deprecated.go b/experimental/libbox/deprecated.go new file mode 100644 index 00000000..0c2f8d8a --- /dev/null +++ b/experimental/libbox/deprecated.go @@ -0,0 +1,33 @@ +package libbox + +import ( + "github.com/sagernet/sing-box/experimental/deprecated" +) + +var _ = deprecated.Note(DeprecatedNote{}) + +type DeprecatedNote struct { + Name string + Description string + DeprecatedVersion string + ScheduledVersion string + EnvName string + MigrationLink string +} + +func (n DeprecatedNote) Impending() bool { + return deprecated.Note(n).Impending() +} + +func (n DeprecatedNote) Message() string { + return deprecated.Note(n).Message() +} + +func (n DeprecatedNote) MessageWithLink() string { + return deprecated.Note(n).MessageWithLink() +} + +type DeprecatedNoteIterator interface { + HasNext() bool + Next() *DeprecatedNote +} diff --git a/experimental/libbox/dns.go b/experimental/libbox/dns.go new file mode 100644 index 00000000..b7b3b0f6 --- /dev/null +++ b/experimental/libbox/dns.go @@ -0,0 +1,150 @@ +package libbox + +import ( + "context" + "net/netip" + "strings" + "syscall" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/task" + + mDNS "github.com/miekg/dns" +) + +type LocalDNSTransport interface { + Raw() bool + Lookup(ctx *ExchangeContext, network string, domain string) error + Exchange(ctx *ExchangeContext, message []byte) error +} + +var _ adapter.DNSTransport = (*platformTransport)(nil) + +type platformTransport struct { + dns.TransportAdapter + iif LocalDNSTransport +} + +func newPlatformTransport(iif LocalDNSTransport, tag string, options option.LocalDNSServerOptions) *platformTransport { + return &platformTransport{ + TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeLocal, tag, options), + iif: iif, + } +} + +func (p *platformTransport) Start(stage adapter.StartStage) error { + return nil +} + +func (p *platformTransport) Close() error { + return nil +} + +func (p *platformTransport) Reset() { +} + +func (p *platformTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + response := &ExchangeContext{ + context: ctx, + } + if p.iif.Raw() { + messageBytes, err := message.Pack() + if err != nil { + return nil, err + } + var responseMessage *mDNS.Msg + var group task.Group + group.Append0(func(ctx context.Context) error { + err = p.iif.Exchange(response, messageBytes) + if err != nil { + return err + } + if response.error != nil { + return response.error + } + responseMessage = &response.message + return nil + }) + err = group.Run(ctx) + if err != nil { + return nil, err + } + return responseMessage, nil + } else { + question := message.Question[0] + var network string + switch question.Qtype { + case mDNS.TypeA: + network = "ip4" + case mDNS.TypeAAAA: + network = "ip6" + default: + return nil, E.New("only IP queries are supported by current version of Android") + } + var responseAddrs []netip.Addr + var group task.Group + group.Append0(func(ctx context.Context) error { + err := p.iif.Lookup(response, network, question.Name) + if err != nil { + return err + } + if response.error != nil { + return response.error + } + responseAddrs = response.addresses + return nil + }) + err := group.Run(ctx) + if err != nil { + return nil, err + } + return dns.FixedResponse(message.Id, question, responseAddrs, C.DefaultDNSTTL), nil + } +} + +type Func interface { + Invoke() error +} + +type ExchangeContext struct { + context context.Context + message mDNS.Msg + addresses []netip.Addr + error error +} + +func (c *ExchangeContext) OnCancel(callback Func) { + go func() { + <-c.context.Done() + callback.Invoke() + }() +} + +func (c *ExchangeContext) Success(result string) { + c.addresses = common.Map(common.Filter(strings.Split(result, "\n"), func(it string) bool { + return !common.IsEmpty(it) + }), func(it string) netip.Addr { + return M.ParseSocksaddrHostPort(it, 0).Unwrap().Addr + }) +} + +func (c *ExchangeContext) RawSuccess(result []byte) { + err := c.message.Unpack(result) + if err != nil { + c.error = E.Cause(err, "parse response") + } +} + +func (c *ExchangeContext) ErrorCode(code int32) { + c.error = dns.RcodeError(code) +} + +func (c *ExchangeContext) ErrnoCode(code int32) { + c.error = syscall.Errno(code) +} diff --git a/experimental/libbox/fdroid.go b/experimental/libbox/fdroid.go new file mode 100644 index 00000000..d574ffd8 --- /dev/null +++ b/experimental/libbox/fdroid.go @@ -0,0 +1,493 @@ +package libbox + +import ( + "archive/zip" + "bytes" + "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +const fdroidUserAgent = "F-Droid 1.21.1" + +type FDroidUpdateInfo struct { + VersionCode int32 + VersionName string + DownloadURL string + FileSize int64 + FileSHA256 string +} + +type FDroidPingResult struct { + URL string + LatencyMs int32 + Error string +} + +type FDroidPingResultIterator interface { + Len() int32 + HasNext() bool + Next() *FDroidPingResult +} + +type fdroidAPIResponse struct { + PackageName string `json:"packageName"` + SuggestedVersionCode int32 `json:"suggestedVersionCode"` + Packages []fdroidAPIPackage `json:"packages"` +} + +type fdroidAPIPackage struct { + VersionName string `json:"versionName"` + VersionCode int32 `json:"versionCode"` +} + +type fdroidEntry struct { + Timestamp int64 `json:"timestamp"` + Version int `json:"version"` + Index fdroidEntryFile `json:"index"` + Diffs map[string]fdroidEntryFile `json:"diffs"` +} + +type fdroidEntryFile struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + NumPackages int `json:"numPackages"` +} + +type fdroidIndexV2 struct { + Packages map[string]fdroidV2Package `json:"packages"` +} + +type fdroidV2Package struct { + Versions map[string]fdroidV2Version `json:"versions"` +} + +type fdroidV2Version struct { + Manifest fdroidV2Manifest `json:"manifest"` + File fdroidV2File `json:"file"` +} + +type fdroidV2Manifest struct { + VersionCode int32 `json:"versionCode"` + VersionName string `json:"versionName"` +} + +type fdroidV2File struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type fdroidIndexV1 struct { + Packages map[string][]fdroidV1Package `json:"packages"` +} + +type fdroidV1Package struct { + VersionCode int32 `json:"versionCode"` + VersionName string `json:"versionName"` + ApkName string `json:"apkName"` + Size int64 `json:"size"` + Hash string `json:"hash"` + HashType string `json:"hashType"` +} + +type fdroidCache struct { + MirrorURL string `json:"mirrorURL"` + Timestamp int64 `json:"timestamp"` + ETag string `json:"etag"` + IsV1 bool `json:"isV1,omitempty"` +} + +func CheckFDroidUpdate(mirrorURL, packageName string, currentVersionCode int32, cachePath string) (*FDroidUpdateInfo, error) { + mirrorURL = strings.TrimRight(mirrorURL, "/") + if strings.Contains(mirrorURL, "f-droid.org") { + return checkFDroidAPI(mirrorURL, packageName, currentVersionCode) + } + client := newFDroidHTTPClient() + defer client.CloseIdleConnections() + cache := loadFDroidCache(cachePath, mirrorURL) + if cache != nil && cache.IsV1 { + return checkFDroidV1(client, mirrorURL, packageName, currentVersionCode, cachePath, cache) + } + return checkFDroidV2(client, mirrorURL, packageName, currentVersionCode, cachePath, cache) +} + +func PingFDroidMirrors(mirrorURLs string) (FDroidPingResultIterator, error) { + urls := strings.Split(mirrorURLs, ",") + results := make([]*FDroidPingResult, len(urls)) + var waitGroup sync.WaitGroup + for i, rawURL := range urls { + waitGroup.Add(1) + go func(index int, target string) { + defer waitGroup.Done() + target = strings.TrimSpace(target) + result := &FDroidPingResult{URL: target} + latency, err := pingTLS(target) + if err != nil { + result.LatencyMs = -1 + result.Error = err.Error() + } else { + result.LatencyMs = int32(latency.Milliseconds()) + } + results[index] = result + }(i, rawURL) + } + waitGroup.Wait() + sort.Slice(results, func(i, j int) bool { + if results[i].LatencyMs < 0 { + return false + } + if results[j].LatencyMs < 0 { + return true + } + return results[i].LatencyMs < results[j].LatencyMs + }) + return newIterator(results), nil +} + +func PingFDroidMirror(mirrorURL string) *FDroidPingResult { + mirrorURL = strings.TrimSpace(mirrorURL) + result := &FDroidPingResult{URL: mirrorURL} + latency, err := pingTLS(mirrorURL) + if err != nil { + result.LatencyMs = -1 + result.Error = err.Error() + } else { + result.LatencyMs = int32(latency.Milliseconds()) + } + return result +} + +func newFDroidHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + } +} + +func newFDroidRequest(requestURL string) (*http.Request, error) { + request, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", fdroidUserAgent) + return request, nil +} + +func checkFDroidAPI(mirrorURL, packageName string, currentVersionCode int32) (*FDroidUpdateInfo, error) { + client := newFDroidHTTPClient() + defer client.CloseIdleConnections() + + apiURL := "https://f-droid.org/api/v1/packages/" + packageName + request, err := newFDroidRequest(apiURL) + if err != nil { + return nil, err + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", response.Status) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var apiResponse fdroidAPIResponse + err = json.Unmarshal(body, &apiResponse) + if err != nil { + return nil, err + } + + var bestCode int32 + var bestName string + for _, pkg := range apiResponse.Packages { + if pkg.VersionCode > currentVersionCode && pkg.VersionCode > bestCode { + bestCode = pkg.VersionCode + bestName = pkg.VersionName + } + } + + if bestCode == 0 { + return nil, nil + } + + return &FDroidUpdateInfo{ + VersionCode: bestCode, + VersionName: bestName, + DownloadURL: "https://f-droid.org/repo/" + packageName + "_" + strconv.FormatInt(int64(bestCode), 10) + ".apk", + }, nil +} + +func checkFDroidV2(client *http.Client, mirrorURL, packageName string, currentVersionCode int32, cachePath string, cache *fdroidCache) (*FDroidUpdateInfo, error) { + entryURL := mirrorURL + "/entry.jar" + request, err := newFDroidRequest(entryURL) + if err != nil { + return nil, err + } + if cache != nil && cache.ETag != "" { + request.Header.Set("If-None-Match", cache.ETag) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + return nil, nil + } + if response.StatusCode == http.StatusNotFound { + writeFDroidCache(cachePath, mirrorURL, 0, "", true) + return checkFDroidV1(client, mirrorURL, packageName, currentVersionCode, cachePath, nil) + } + if response.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", response.Status, ": ", entryURL) + } + + jarData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + etag := response.Header.Get("ETag") + + var entry fdroidEntry + err = readJSONFromJar(jarData, "entry.json", &entry) + if err != nil { + return nil, E.Cause(err, "read entry.jar") + } + + if entry.Timestamp == 0 { + return nil, E.New("entry.json not found in entry.jar") + } + + if cache != nil && cache.Timestamp == entry.Timestamp { + writeFDroidCache(cachePath, mirrorURL, entry.Timestamp, etag, false) + return nil, nil + } + + var indexURL string + if cache != nil { + cachedTimestamp := strconv.FormatInt(cache.Timestamp, 10) + if diff, ok := entry.Diffs[cachedTimestamp]; ok { + indexURL = mirrorURL + "/" + diff.Name + } + } + if indexURL == "" { + indexURL = mirrorURL + "/" + entry.Index.Name + } + + indexRequest, err := newFDroidRequest(indexURL) + if err != nil { + return nil, err + } + + indexResponse, err := client.Do(indexRequest) + if err != nil { + return nil, err + } + defer indexResponse.Body.Close() + + if indexResponse.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", indexResponse.Status, ": ", indexURL) + } + + indexData, err := io.ReadAll(indexResponse.Body) + if err != nil { + return nil, err + } + + var index fdroidIndexV2 + err = json.Unmarshal(indexData, &index) + if err != nil { + return nil, err + } + + writeFDroidCache(cachePath, mirrorURL, entry.Timestamp, etag, false) + + pkg, ok := index.Packages[packageName] + if !ok { + return nil, nil + } + + var bestCode int32 + var bestVersion fdroidV2Version + for _, version := range pkg.Versions { + if version.Manifest.VersionCode > currentVersionCode && version.Manifest.VersionCode > bestCode { + bestCode = version.Manifest.VersionCode + bestVersion = version + } + } + + if bestCode == 0 { + return nil, nil + } + + return &FDroidUpdateInfo{ + VersionCode: bestCode, + VersionName: bestVersion.Manifest.VersionName, + DownloadURL: mirrorURL + "/" + bestVersion.File.Name, + FileSize: bestVersion.File.Size, + FileSHA256: bestVersion.File.SHA256, + }, nil +} + +func checkFDroidV1(client *http.Client, mirrorURL, packageName string, currentVersionCode int32, cachePath string, cache *fdroidCache) (*FDroidUpdateInfo, error) { + indexURL := mirrorURL + "/index-v1.jar" + + request, err := newFDroidRequest(indexURL) + if err != nil { + return nil, err + } + if cache != nil && cache.ETag != "" { + request.Header.Set("If-None-Match", cache.ETag) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + return nil, nil + } + if response.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", response.Status, ": ", indexURL) + } + + jarData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + etag := response.Header.Get("ETag") + + var index fdroidIndexV1 + err = readJSONFromJar(jarData, "index-v1.json", &index) + if err != nil { + return nil, E.Cause(err, "read index-v1.jar") + } + + writeFDroidCache(cachePath, mirrorURL, 0, etag, true) + + packages, ok := index.Packages[packageName] + if !ok { + return nil, nil + } + + var bestCode int32 + var bestPackage fdroidV1Package + for _, pkg := range packages { + if pkg.VersionCode > currentVersionCode && pkg.VersionCode > bestCode { + bestCode = pkg.VersionCode + bestPackage = pkg + } + } + + if bestCode == 0 { + return nil, nil + } + + return &FDroidUpdateInfo{ + VersionCode: bestCode, + VersionName: bestPackage.VersionName, + DownloadURL: mirrorURL + "/" + bestPackage.ApkName, + FileSize: bestPackage.Size, + FileSHA256: bestPackage.Hash, + }, nil +} + +func readJSONFromJar(jarData []byte, fileName string, destination any) error { + zipReader, err := zip.NewReader(bytes.NewReader(jarData), int64(len(jarData))) + if err != nil { + return err + } + for _, file := range zipReader.File { + if file.Name != fileName { + continue + } + reader, err := file.Open() + if err != nil { + return err + } + data, err := io.ReadAll(reader) + reader.Close() + if err != nil { + return err + } + return json.Unmarshal(data, destination) + } + return nil +} + +func pingTLS(mirrorURL string) (time.Duration, error) { + parsed, err := url.Parse(mirrorURL) + if err != nil { + return 0, err + } + host := parsed.Host + if !strings.Contains(host, ":") { + host = host + ":443" + } + + dialer := &net.Dialer{Timeout: 5 * time.Second} + start := time.Now() + conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{}) + if err != nil { + return 0, err + } + latency := time.Since(start) + conn.Close() + return latency, nil +} + +func loadFDroidCache(cachePath, mirrorURL string) *fdroidCache { + cacheFile := filepath.Join(cachePath, "fdroid_cache.json") + data, err := os.ReadFile(cacheFile) + if err != nil { + return nil + } + var cache fdroidCache + err = json.Unmarshal(data, &cache) + if err != nil { + return nil + } + if cache.MirrorURL != mirrorURL { + return nil + } + return &cache +} + +func writeFDroidCache(cachePath, mirrorURL string, timestamp int64, etag string, isV1 bool) { + cache := fdroidCache{ + MirrorURL: mirrorURL, + Timestamp: timestamp, + ETag: etag, + IsV1: isV1, + } + data, err := json.Marshal(cache) + if err != nil { + return + } + os.MkdirAll(cachePath, 0o755) + os.WriteFile(filepath.Join(cachePath, "fdroid_cache.json"), data, 0o644) +} diff --git a/experimental/libbox/fdroid_mirrors.go b/experimental/libbox/fdroid_mirrors.go new file mode 100644 index 00000000..4ca82555 --- /dev/null +++ b/experimental/libbox/fdroid_mirrors.go @@ -0,0 +1,92 @@ +package libbox + +type FDroidMirror struct { + URL string + Country string + Name string +} + +type FDroidMirrorIterator interface { + Len() int32 + HasNext() bool + Next() *FDroidMirror +} + +var builtinFDroidMirrors = []FDroidMirror{ + // Official + {URL: "https://f-droid.org/repo", Country: "Official", Name: "f-droid.org"}, + {URL: "https://cloudflare.f-droid.org/repo", Country: "Official", Name: "Cloudflare CDN"}, + + // China + {URL: "https://mirrors.tuna.tsinghua.edu.cn/fdroid/repo", Country: "China", Name: "Tsinghua TUNA"}, + {URL: "https://mirrors.nju.edu.cn/fdroid/repo", Country: "China", Name: "Nanjing University"}, + {URL: "https://mirror.iscas.ac.cn/fdroid/repo", Country: "China", Name: "ISCAS"}, + {URL: "https://mirror.nyist.edu.cn/fdroid/repo", Country: "China", Name: "NYIST"}, + {URL: "https://mirrors.cqupt.edu.cn/fdroid/repo", Country: "China", Name: "CQUPT"}, + {URL: "https://mirrors.shanghaitech.edu.cn/fdroid/repo", Country: "China", Name: "ShanghaiTech"}, + + // India + {URL: "https://mirror.hyd.albony.in/fdroid/repo", Country: "India", Name: "Albony Hyderabad"}, + {URL: "https://mirror.del2.albony.in/fdroid/repo", Country: "India", Name: "Albony Delhi"}, + + // Taiwan + {URL: "https://mirror.ossplanet.net/fdroid/repo", Country: "Taiwan", Name: "OSSPlanet"}, + + // France + {URL: "https://fdroid.tetaneutral.net/fdroid/repo", Country: "France", Name: "tetaneutral.net"}, + {URL: "https://mirror.freedif.org/fdroid/repo", Country: "France", Name: "FreeDif"}, + + // Germany + {URL: "https://ftp.fau.de/fdroid/repo", Country: "Germany", Name: "FAU Erlangen"}, + {URL: "https://ftp.agdsn.de/fdroid/repo", Country: "Germany", Name: "AGDSN Dresden"}, + {URL: "https://ftp.gwdg.de/pub/android/fdroid/repo", Country: "Germany", Name: "GWDG"}, + {URL: "https://mirror.level66.network/fdroid/repo", Country: "Germany", Name: "Level66"}, + {URL: "https://mirror.mci-1.serverforge.org/fdroid/repo", Country: "Germany", Name: "ServerForge"}, + + // Netherlands + {URL: "https://ftp.snt.utwente.nl/pub/software/fdroid/repo", Country: "Netherlands", Name: "University of Twente"}, + + // Sweden + {URL: "https://ftp.lysator.liu.se/pub/fdroid/repo", Country: "Sweden", Name: "Lysator"}, + + // Denmark + {URL: "https://mirrors.dotsrc.org/fdroid/repo", Country: "Denmark", Name: "dotsrc.org"}, + + // Austria + {URL: "https://mirror.kumi.systems/fdroid/repo", Country: "Austria", Name: "Kumi Systems"}, + + // Switzerland + {URL: "https://mirror.init7.net/fdroid/repo", Country: "Switzerland", Name: "Init7"}, + + // Romania + {URL: "https://mirrors.hostico.ro/fdroid/repo", Country: "Romania", Name: "Hostico"}, + {URL: "https://mirrors.chroot.ro/fdroid/repo", Country: "Romania", Name: "Chroot"}, + {URL: "https://ftp.lug.ro/fdroid/repo", Country: "Romania", Name: "LUG Romania"}, + + // US + {URL: "https://plug-mirror.rcac.purdue.edu/fdroid/repo", Country: "US", Name: "Purdue"}, + {URL: "https://mirror.fcix.net/fdroid/repo", Country: "US", Name: "FCIX"}, + {URL: "https://opencolo.mm.fcix.net/fdroid/repo", Country: "US", Name: "OpenColo"}, + {URL: "https://forksystems.mm.fcix.net/fdroid/repo", Country: "US", Name: "Fork Systems"}, + {URL: "https://southfront.mm.fcix.net/fdroid/repo", Country: "US", Name: "South Front"}, + {URL: "https://ziply.mm.fcix.net/fdroid/repo", Country: "US", Name: "Ziply"}, + + // Canada + {URL: "https://mirror.quantum5.ca/fdroid/repo", Country: "Canada", Name: "Quantum5"}, + + // Australia + {URL: "https://mirror.aarnet.edu.au/fdroid/repo", Country: "Australia", Name: "AARNet"}, + + // Other + {URL: "https://mirror.cyberbits.eu/fdroid/repo", Country: "Europe", Name: "Cyberbits EU"}, + {URL: "https://mirror.eu.ossplanet.net/fdroid/repo", Country: "Europe", Name: "OSSPlanet EU"}, + {URL: "https://mirror.cyberbits.asia/fdroid/repo", Country: "Asia", Name: "Cyberbits Asia"}, + {URL: "https://mirrors.jevincanders.net/fdroid/repo", Country: "US", Name: "Jevincanders"}, + {URL: "https://mirrors.komogoto.com/fdroid/repo", Country: "US", Name: "Komogoto"}, + {URL: "https://fdroid.rasp.sh/fdroid/repo", Country: "Europe", Name: "rasp.sh"}, + {URL: "https://mirror.gofoss.xyz/fdroid/repo", Country: "Europe", Name: "GoFOSS"}, +} + +func GetFDroidMirrors() FDroidMirrorIterator { + return newPtrIterator(builtinFDroidMirrors) +} diff --git a/experimental/libbox/ffi.json b/experimental/libbox/ffi.json new file mode 100644 index 00000000..81fae27d --- /dev/null +++ b/experimental/libbox/ffi.json @@ -0,0 +1,257 @@ +{ + "version": 1, + "variables": { + "VERSION": "$(go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest)", + "WORKSPACE_ROOT": "../../..", + "DEPLOY_ANDROID": "${WORKSPACE_ROOT}/sing-box-for-android/app/libs", + "DEPLOY_APPLE": "${WORKSPACE_ROOT}/sing-box-for-apple", + "DEPLOY_WINDOWS": "${WORKSPACE_ROOT}/sing-box-for-windows/local-packages" + }, + "packages": [ + { + "id": "libbox", + "path": ".", + "java_package": "io.nekohasekai.libbox", + "csharp_namespace": "SagerNet", + "csharp_entrypoint": "Libbox", + "apple_prefix": "Libbox" + } + ], + "builds": [ + { + "id": "android-main", + "packages": ["libbox"], + "default": { + "tags": [ + "with_gvisor", + "with_quic", + "with_wireguard", + "with_utls", + "with_naive_outbound", + "with_clash_api", + "badlinkname", + "tfogo_checklinkname0", + "with_tailscale", + "ts_omit_logtail", + "ts_omit_ssh", + "ts_omit_drive", + "ts_omit_taildrop", + "ts_omit_webclient", + "ts_omit_doctor", + "ts_omit_capture", + "ts_omit_kube", + "ts_omit_aws", + "ts_omit_synology", + "ts_omit_bird" + ], + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "trimpath": true + } + }, + { + "id": "android-legacy", + "packages": ["libbox"], + "default": { + "tags": [ + "with_gvisor", + "with_quic", + "with_wireguard", + "with_utls", + "with_clash_api", + "badlinkname", + "tfogo_checklinkname0", + "with_tailscale", + "ts_omit_logtail", + "ts_omit_ssh", + "ts_omit_drive", + "ts_omit_taildrop", + "ts_omit_webclient", + "ts_omit_doctor", + "ts_omit_capture", + "ts_omit_kube", + "ts_omit_aws", + "ts_omit_synology", + "ts_omit_bird" + ], + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "trimpath": true + } + }, + { + "id": "apple", + "packages": ["libbox"], + "default": { + "tags": [ + "with_gvisor", + "with_quic", + "with_wireguard", + "with_utls", + "with_naive_outbound", + "with_clash_api", + "badlinkname", + "tfogo_checklinkname0", + "with_dhcp", + "grpcnotrace", + "with_tailscale", + "ts_omit_logtail", + "ts_omit_ssh", + "ts_omit_drive", + "ts_omit_taildrop", + "ts_omit_webclient", + "ts_omit_doctor", + "ts_omit_capture", + "ts_omit_kube", + "ts_omit_aws", + "ts_omit_synology", + "ts_omit_bird" + ], + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "trimpath": true + }, + "overrides": [ + { + "match": { "os": "ios" }, + "tags_append": ["with_low_memory"] + }, + { + "match": { "os": "tvos" }, + "tags_append": ["with_low_memory"] + } + ] + }, + { + "id": "windows", + "packages": ["libbox"], + "default": { + "tags": [ + "with_gvisor", + "with_quic", + "with_wireguard", + "with_utls", + "with_naive_outbound", + "with_purego", + "with_clash_api", + "badlinkname", + "tfogo_checklinkname0", + "with_tailscale", + "ts_omit_logtail", + "ts_omit_ssh", + "ts_omit_drive", + "ts_omit_taildrop", + "ts_omit_webclient", + "ts_omit_doctor", + "ts_omit_capture", + "ts_omit_kube", + "ts_omit_aws", + "ts_omit_synology", + "ts_omit_bird" + ], + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "trimpath": true + } + } + ], + "platforms": [ + { + "type": "android", + "build": "android-main", + "min_sdk": 23, + "ndk_version": "28.0.13004108", + "lib_name": "box", + "languages": [{ "type": "java" }], + "artifacts": [ + { + "type": "aar", + "output_path": "libbox.aar", + "execute_after": [ + "if [ -d \"${DEPLOY_ANDROID}\" ]; then", + " rm -f \"${DEPLOY_ANDROID}/$$(basename \"${OUTPUT_PATH}\")\"", + " mv \"${OUTPUT_PATH}\" \"${DEPLOY_ANDROID}/\"", + "fi" + ] + } + ] + }, + { + "type": "android", + "build": "android-legacy", + "min_sdk": 21, + "ndk_version": "28.0.13004108", + "lib_name": "box", + "languages": [{ "type": "java" }], + "artifacts": [ + { + "type": "aar", + "output_path": "libbox-legacy.aar", + "execute_after": [ + "if [ -d \"${DEPLOY_ANDROID}\" ]; then", + " rm -f \"${DEPLOY_ANDROID}/$$(basename \"${OUTPUT_PATH}\")\"", + " mv \"${OUTPUT_PATH}\" \"${DEPLOY_ANDROID}/\"", + "fi" + ] + } + ] + }, + { + "type": "apple", + "build": "apple", + "targets": [ + "ios/arm64", + "ios/simulator/arm64", + "ios/simulator/amd64", + "tvos/arm64", + "tvos/simulator/arm64", + "tvos/simulator/amd64", + "macos/arm64", + "macos/amd64" + ], + "languages": [{ "type": "objc" }], + "artifacts": [ + { + "type": "xcframework", + "module_name": "Libbox", + "execute_after": [ + "if [ -d \"${DEPLOY_APPLE}\" ]; then", + " rm -rf \"${DEPLOY_APPLE}/${MODULE_NAME}.xcframework\"", + " mv \"${OUTPUT_PATH}\" \"${DEPLOY_APPLE}/\"", + "fi" + ] + } + ] + }, + { + "type": "csharp", + "build": "windows", + "targets": [ + "windows/amd64" + ], + "languages": [{ "type": "csharp" }], + "artifacts": [ + { + "type": "nuget", + "package_id": "SagerNet.Libbox", + "package_version": "0.0.0-local", + "execute_after": { + "windows": [ + "$$deployPath = '${DEPLOY_WINDOWS}'", + "if (Test-Path $$deployPath) {", + " Remove-Item \"$$deployPath\\${PACKAGE_ID}.*.nupkg\" -ErrorAction SilentlyContinue", + " Move-Item -Force '${OUTPUT_PATH}' \"$$deployPath\\\"", + " $$cachePath = if ($$env:NUGET_PACKAGES) { $$env:NUGET_PACKAGES } else { \"$$env:USERPROFILE\\.nuget\\packages\" }", + " Remove-Item -Recurse -Force \"$$cachePath\\sagernet.libbox\\${PACKAGE_VERSION}\" -ErrorAction SilentlyContinue", + "}" + ], + "default": [ + "if [ -d \"${DEPLOY_WINDOWS}\" ]; then", + " rm -f \"${DEPLOY_WINDOWS}/${PACKAGE_ID}.*.nupkg\"", + " mv \"${OUTPUT_PATH}\" \"${DEPLOY_WINDOWS}/\"", + " cache_path=\"$${NUGET_PACKAGES:-$${HOME}/.nuget/packages}\"", + " rm -rf \"$${cache_path}/sagernet.libbox/${PACKAGE_VERSION}\"", + "fi" + ] + } + } + ] + } + ] +} diff --git a/experimental/libbox/http.go b/experimental/libbox/http.go new file mode 100644 index 00000000..69a23d26 --- /dev/null +++ b/experimental/libbox/http.go @@ -0,0 +1,274 @@ +package libbox + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "net/url" + "os" + "strconv" + "sync" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/protocol/socks/socks5" +) + +type HTTPClient interface { + RestrictedTLS() + ModernTLS() + PinnedTLS12() + PinnedSHA256(sumHex string) + TrySocks5(port int32) + KeepAlive() + NewRequest() HTTPRequest + Close() +} + +type HTTPRequest interface { + SetURL(link string) error + SetMethod(method string) + SetHeader(key string, value string) + SetContent(content []byte) + SetContentString(content string) + RandomUserAgent() + SetUserAgent(userAgent string) + Execute() (HTTPResponse, error) +} + +type HTTPResponse interface { + GetContent() (*StringBox, error) + WriteTo(path string) error + WriteToWithProgress(path string, handler HTTPResponseWriteToProgressHandler) error +} + +type HTTPResponseWriteToProgressHandler interface { + Update(progress int64, total int64) +} + +var ( + _ HTTPClient = (*httpClient)(nil) + _ HTTPRequest = (*httpRequest)(nil) + _ HTTPResponse = (*httpResponse)(nil) +) + +type httpClient struct { + tls tls.Config + client http.Client + transport http.Transport +} + +func NewHTTPClient() HTTPClient { + client := new(httpClient) + client.client.Transport = &client.transport + client.transport.ForceAttemptHTTP2 = true + client.transport.TLSHandshakeTimeout = C.TCPTimeout + client.transport.TLSClientConfig = &client.tls + client.transport.DisableKeepAlives = true + return client +} + +func (c *httpClient) ModernTLS() { + c.setTLSVersion(tls.VersionTLS12, 0, func(suite *tls.CipherSuite) bool { return true }) +} + +func (c *httpClient) RestrictedTLS() { + c.setTLSVersion(tls.VersionTLS13, 0, func(suite *tls.CipherSuite) bool { + return common.Contains(suite.SupportedVersions, uint16(tls.VersionTLS13)) + }) +} + +func (c *httpClient) setTLSVersion(minVersion, maxVersion uint16, filter func(*tls.CipherSuite) bool) { + c.tls.MinVersion = minVersion + if maxVersion != 0 { + c.tls.MaxVersion = maxVersion + } + c.tls.CipherSuites = common.Map(common.Filter(tls.CipherSuites(), filter), func(it *tls.CipherSuite) uint16 { + return it.ID + }) +} + +func (c *httpClient) PinnedTLS12() { + c.setTLSVersion(tls.VersionTLS12, tls.VersionTLS12, func(suite *tls.CipherSuite) bool { return true }) +} + +func (c *httpClient) PinnedSHA256(sumHex string) { + c.tls.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + for _, rawCert := range rawCerts { + certSum := sha256.Sum256(rawCert) + if sumHex == hex.EncodeToString(certSum[:]) { + return nil + } + } + return E.New("pinned sha256 sum mismatch") + } +} + +func (c *httpClient) TrySocks5(port int32) { + dialer := new(net.Dialer) + c.transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + for { + socksConn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(int(port))) + if err != nil { + break + } + _, err = socks.ClientHandshake5(socksConn, socks5.CommandConnect, M.ParseSocksaddr(addr), "", "") + if err != nil { + break + } + //nolint:staticcheck + return socksConn, err + } + return dialer.DialContext(ctx, network, addr) + } +} + +func (c *httpClient) KeepAlive() { + c.transport.DisableKeepAlives = false +} + +func (c *httpClient) NewRequest() HTTPRequest { + req := &httpRequest{httpClient: c} + req.request = http.Request{ + Method: "GET", + Header: http.Header{}, + } + return req +} + +func (c *httpClient) Close() { + c.transport.CloseIdleConnections() +} + +type httpRequest struct { + *httpClient + request http.Request +} + +func (r *httpRequest) SetURL(link string) (err error) { + r.request.URL, err = url.Parse(link) + if err != nil { + return + } + if r.request.URL.User != nil { + user := r.request.URL.User.Username() + password, _ := r.request.URL.User.Password() + r.request.SetBasicAuth(user, password) + } + return +} + +func (r *httpRequest) SetMethod(method string) { + r.request.Method = method +} + +func (r *httpRequest) SetHeader(key string, value string) { + r.request.Header.Set(key, value) +} + +func (r *httpRequest) RandomUserAgent() { + r.request.Header.Set("User-Agent", fmt.Sprintf("curl/7.%d.%d", rand.Int()%54, rand.Int()%2)) +} + +func (r *httpRequest) SetUserAgent(userAgent string) { + r.request.Header.Set("User-Agent", userAgent) +} + +func (r *httpRequest) SetContent(content []byte) { + r.request.Body = io.NopCloser(bytes.NewReader(content)) + r.request.ContentLength = int64(len(content)) +} + +func (r *httpRequest) SetContentString(content string) { + r.SetContent([]byte(content)) +} + +func (r *httpRequest) Execute() (HTTPResponse, error) { + response, err := r.client.Do(&r.request) + if err != nil { + return nil, err + } + httpResp := &httpResponse{Response: response} + if response.StatusCode != http.StatusOK { + return nil, errors.New(httpResp.errorString()) + } + return httpResp, nil +} + +type httpResponse struct { + *http.Response + + getContentOnce sync.Once + content []byte + contentError error +} + +func (h *httpResponse) errorString() string { + content, err := h.GetContent() + if err != nil { + return fmt.Sprint("HTTP ", h.Status) + } + return fmt.Sprint("HTTP ", h.Status, ": ", content) +} + +func (h *httpResponse) GetContent() (*StringBox, error) { + h.getContentOnce.Do(func() { + defer h.Body.Close() + h.content, h.contentError = io.ReadAll(h.Body) + }) + if h.contentError != nil { + return nil, h.contentError + } + return wrapString(string(h.content)), nil +} + +func (h *httpResponse) WriteTo(path string) error { + defer h.Body.Close() + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + return common.Error(bufio.Copy(file, h.Body)) +} + +func (h *httpResponse) WriteToWithProgress(path string, handler HTTPResponseWriteToProgressHandler) error { + defer h.Body.Close() + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + return common.Error(bufio.Copy(&progressWriter{ + writer: file, + handler: handler, + total: h.ContentLength, + }, h.Body)) +} + +type progressWriter struct { + writer io.Writer + handler HTTPResponseWriteToProgressHandler + total int64 + written int64 +} + +func (w *progressWriter) Write(p []byte) (int, error) { + n, err := w.writer.Write(p) + w.written += int64(n) + w.handler.Update(w.written, w.total) + return n, err +} diff --git a/experimental/libbox/internal/oomprofile/builder.go b/experimental/libbox/internal/oomprofile/builder.go new file mode 100644 index 00000000..1f59078a --- /dev/null +++ b/experimental/libbox/internal/oomprofile/builder.go @@ -0,0 +1,390 @@ +//go:build darwin || linux || windows + +package oomprofile + +import ( + "fmt" + "io" + "runtime" + "time" +) + +const ( + tagProfile_SampleType = 1 + tagProfile_Sample = 2 + tagProfile_Mapping = 3 + tagProfile_Location = 4 + tagProfile_Function = 5 + tagProfile_StringTable = 6 + tagProfile_TimeNanos = 9 + tagProfile_PeriodType = 11 + tagProfile_Period = 12 + tagProfile_DefaultSampleType = 14 + + tagValueType_Type = 1 + tagValueType_Unit = 2 + + tagSample_Location = 1 + tagSample_Value = 2 + tagSample_Label = 3 + + tagLabel_Key = 1 + tagLabel_Str = 2 + tagLabel_Num = 3 + + tagMapping_ID = 1 + tagMapping_Start = 2 + tagMapping_Limit = 3 + tagMapping_Offset = 4 + tagMapping_Filename = 5 + tagMapping_BuildID = 6 + tagMapping_HasFunctions = 7 + tagMapping_HasFilenames = 8 + tagMapping_HasLineNumbers = 9 + tagMapping_HasInlineFrames = 10 + + tagLocation_ID = 1 + tagLocation_MappingID = 2 + tagLocation_Address = 3 + tagLocation_Line = 4 + + tagLine_FunctionID = 1 + tagLine_Line = 2 + + tagFunction_ID = 1 + tagFunction_Name = 2 + tagFunction_SystemName = 3 + tagFunction_Filename = 4 + tagFunction_StartLine = 5 +) + +type memMap struct { + start uintptr + end uintptr + offset uint64 + file string + buildID string + funcs symbolizeFlag + fake bool +} + +type symbolizeFlag uint8 + +const ( + lookupTried symbolizeFlag = 1 << iota + lookupFailed +) + +func newProfileBuilder(w io.Writer) *profileBuilder { + builder := &profileBuilder{ + start: time.Now(), + w: w, + strings: []string{""}, + stringMap: map[string]int{"": 0}, + locs: map[uintptr]locInfo{}, + funcs: map[string]int{}, + } + builder.readMapping() + return builder +} + +func (b *profileBuilder) stringIndex(s string) int64 { + id, ok := b.stringMap[s] + if !ok { + id = len(b.strings) + b.strings = append(b.strings, s) + b.stringMap[s] = id + } + return int64(id) +} + +func (b *profileBuilder) flush() { + const dataFlush = 4096 + if b.err != nil || b.pb.nest != 0 || len(b.pb.data) <= dataFlush { + return + } + + _, b.err = b.w.Write(b.pb.data) + b.pb.data = b.pb.data[:0] +} + +func (b *profileBuilder) pbValueType(tag int, typ string, unit string) { + start := b.pb.startMessage() + b.pb.int64(tagValueType_Type, b.stringIndex(typ)) + b.pb.int64(tagValueType_Unit, b.stringIndex(unit)) + b.pb.endMessage(tag, start) +} + +func (b *profileBuilder) pbSample(values []int64, locs []uint64, labels func()) { + start := b.pb.startMessage() + b.pb.int64s(tagSample_Value, values) + b.pb.uint64s(tagSample_Location, locs) + if labels != nil { + labels() + } + b.pb.endMessage(tagProfile_Sample, start) + b.flush() +} + +func (b *profileBuilder) pbLabel(tag int, key string, str string, num int64) { + start := b.pb.startMessage() + b.pb.int64Opt(tagLabel_Key, b.stringIndex(key)) + b.pb.int64Opt(tagLabel_Str, b.stringIndex(str)) + b.pb.int64Opt(tagLabel_Num, num) + b.pb.endMessage(tag, start) +} + +func (b *profileBuilder) pbLine(tag int, funcID uint64, line int64) { + start := b.pb.startMessage() + b.pb.uint64Opt(tagLine_FunctionID, funcID) + b.pb.int64Opt(tagLine_Line, line) + b.pb.endMessage(tag, start) +} + +func (b *profileBuilder) pbMapping(tag int, id uint64, base uint64, limit uint64, offset uint64, file string, buildID string, hasFuncs bool) { + start := b.pb.startMessage() + b.pb.uint64Opt(tagMapping_ID, id) + b.pb.uint64Opt(tagMapping_Start, base) + b.pb.uint64Opt(tagMapping_Limit, limit) + b.pb.uint64Opt(tagMapping_Offset, offset) + b.pb.int64Opt(tagMapping_Filename, b.stringIndex(file)) + b.pb.int64Opt(tagMapping_BuildID, b.stringIndex(buildID)) + if hasFuncs { + b.pb.bool(tagMapping_HasFunctions, true) + } + b.pb.endMessage(tag, start) +} + +func (b *profileBuilder) build() error { + if b.err != nil { + return b.err + } + + b.pb.int64Opt(tagProfile_TimeNanos, b.start.UnixNano()) + for i, mapping := range b.mem { + hasFunctions := mapping.funcs == lookupTried + b.pbMapping(tagProfile_Mapping, uint64(i+1), uint64(mapping.start), uint64(mapping.end), mapping.offset, mapping.file, mapping.buildID, hasFunctions) + } + b.pb.strings(tagProfile_StringTable, b.strings) + if b.err != nil { + return b.err + } + _, err := b.w.Write(b.pb.data) + return err +} + +func allFrames(addr uintptr) ([]runtime.Frame, symbolizeFlag) { + frames := runtime.CallersFrames([]uintptr{addr}) + frame, more := frames.Next() + if frame.Function == "runtime.goexit" { + return nil, 0 + } + + result := lookupTried + if frame.PC == 0 || frame.Function == "" || frame.File == "" || frame.Line == 0 { + result |= lookupFailed + } + if frame.PC == 0 { + frame.PC = addr - 1 + } + + ret := []runtime.Frame{frame} + for frame.Function != "runtime.goexit" && more { + frame, more = frames.Next() + ret = append(ret, frame) + } + return ret, result +} + +type locInfo struct { + id uint64 + + pcs []uintptr + + firstPCFrames []runtime.Frame + firstPCSymbolizeResult symbolizeFlag +} + +func (b *profileBuilder) appendLocsForStack(locs []uint64, stk []uintptr) []uint64 { + b.deck.reset() + origStk := stk + stk = runtimeExpandFinalInlineFrame(stk) + + for len(stk) > 0 { + addr := stk[0] + if loc, ok := b.locs[addr]; ok { + if len(b.deck.pcs) > 0 { + if b.deck.tryAdd(addr, loc.firstPCFrames, loc.firstPCSymbolizeResult) { + stk = stk[1:] + continue + } + } + if id := b.emitLocation(); id > 0 { + locs = append(locs, id) + } + locs = append(locs, loc.id) + if len(loc.pcs) > len(stk) { + panic(fmt.Sprintf("stack too short to match cached location; stk = %#x, loc.pcs = %#x, original stk = %#x", stk, loc.pcs, origStk)) + } + stk = stk[len(loc.pcs):] + continue + } + + frames, symbolizeResult := allFrames(addr) + if len(frames) == 0 { + if id := b.emitLocation(); id > 0 { + locs = append(locs, id) + } + stk = stk[1:] + continue + } + + if b.deck.tryAdd(addr, frames, symbolizeResult) { + stk = stk[1:] + continue + } + if id := b.emitLocation(); id > 0 { + locs = append(locs, id) + } + + if loc, ok := b.locs[addr]; ok { + locs = append(locs, loc.id) + stk = stk[len(loc.pcs):] + } else { + b.deck.tryAdd(addr, frames, symbolizeResult) + stk = stk[1:] + } + } + if id := b.emitLocation(); id > 0 { + locs = append(locs, id) + } + return locs +} + +type pcDeck struct { + pcs []uintptr + frames []runtime.Frame + symbolizeResult symbolizeFlag + + firstPCFrames int + firstPCSymbolizeResult symbolizeFlag +} + +func (d *pcDeck) reset() { + d.pcs = d.pcs[:0] + d.frames = d.frames[:0] + d.symbolizeResult = 0 + d.firstPCFrames = 0 + d.firstPCSymbolizeResult = 0 +} + +func (d *pcDeck) tryAdd(pc uintptr, frames []runtime.Frame, symbolizeResult symbolizeFlag) bool { + if existing := len(d.frames); existing > 0 { + newFrame := frames[0] + last := d.frames[existing-1] + if last.Func != nil { + return false + } + if last.Entry == 0 || newFrame.Entry == 0 { + return false + } + if last.Entry != newFrame.Entry { + return false + } + if runtimeFrameSymbolName(&last) == runtimeFrameSymbolName(&newFrame) { + return false + } + } + + d.pcs = append(d.pcs, pc) + d.frames = append(d.frames, frames...) + d.symbolizeResult |= symbolizeResult + if len(d.pcs) == 1 { + d.firstPCFrames = len(d.frames) + d.firstPCSymbolizeResult = symbolizeResult + } + return true +} + +func (b *profileBuilder) emitLocation() uint64 { + if len(b.deck.pcs) == 0 { + return 0 + } + defer b.deck.reset() + + addr := b.deck.pcs[0] + firstFrame := b.deck.frames[0] + + type newFunc struct { + id uint64 + name string + file string + startLine int64 + } + + newFuncs := make([]newFunc, 0, 8) + id := uint64(len(b.locs)) + 1 + b.locs[addr] = locInfo{ + id: id, + pcs: append([]uintptr{}, b.deck.pcs...), + firstPCFrames: append([]runtime.Frame{}, b.deck.frames[:b.deck.firstPCFrames]...), + firstPCSymbolizeResult: b.deck.firstPCSymbolizeResult, + } + + start := b.pb.startMessage() + b.pb.uint64Opt(tagLocation_ID, id) + b.pb.uint64Opt(tagLocation_Address, uint64(firstFrame.PC)) + for _, frame := range b.deck.frames { + funcName := runtimeFrameSymbolName(&frame) + funcID := uint64(b.funcs[funcName]) + if funcID == 0 { + funcID = uint64(len(b.funcs)) + 1 + b.funcs[funcName] = int(funcID) + newFuncs = append(newFuncs, newFunc{ + id: funcID, + name: funcName, + file: frame.File, + startLine: int64(runtimeFrameStartLine(&frame)), + }) + } + b.pbLine(tagLocation_Line, funcID, int64(frame.Line)) + } + for i := range b.mem { + if (b.mem[i].start <= addr && addr < b.mem[i].end) || b.mem[i].fake { + b.pb.uint64Opt(tagLocation_MappingID, uint64(i+1)) + mapping := b.mem[i] + mapping.funcs |= b.deck.symbolizeResult + b.mem[i] = mapping + break + } + } + b.pb.endMessage(tagProfile_Location, start) + + for _, fn := range newFuncs { + start := b.pb.startMessage() + b.pb.uint64Opt(tagFunction_ID, fn.id) + b.pb.int64Opt(tagFunction_Name, b.stringIndex(fn.name)) + b.pb.int64Opt(tagFunction_SystemName, b.stringIndex(fn.name)) + b.pb.int64Opt(tagFunction_Filename, b.stringIndex(fn.file)) + b.pb.int64Opt(tagFunction_StartLine, fn.startLine) + b.pb.endMessage(tagProfile_Function, start) + } + + b.flush() + return id +} + +func (b *profileBuilder) addMapping(lo uint64, hi uint64, offset uint64, file string, buildID string) { + b.addMappingEntry(lo, hi, offset, file, buildID, false) +} + +func (b *profileBuilder) addMappingEntry(lo uint64, hi uint64, offset uint64, file string, buildID string, fake bool) { + b.mem = append(b.mem, memMap{ + start: uintptr(lo), + end: uintptr(hi), + offset: offset, + file: file, + buildID: buildID, + fake: fake, + }) +} diff --git a/experimental/libbox/internal/oomprofile/defs_darwin_amd64.go b/experimental/libbox/internal/oomprofile/defs_darwin_amd64.go new file mode 100644 index 00000000..8a30074c --- /dev/null +++ b/experimental/libbox/internal/oomprofile/defs_darwin_amd64.go @@ -0,0 +1,24 @@ +//go:build darwin && amd64 + +package oomprofile + +type machVMRegionBasicInfoData struct { + Protection int32 + MaxProtection int32 + Inheritance uint32 + Shared uint32 + Reserved uint32 + Offset [8]byte + Behavior int32 + UserWiredCount uint16 + PadCgo1 [2]byte +} + +const ( + _VM_PROT_READ = 0x1 + _VM_PROT_EXECUTE = 0x4 + + _MACH_SEND_INVALID_DEST = 0x10000003 + + _MAXPATHLEN = 0x400 +) diff --git a/experimental/libbox/internal/oomprofile/defs_darwin_arm64.go b/experimental/libbox/internal/oomprofile/defs_darwin_arm64.go new file mode 100644 index 00000000..2fd46590 --- /dev/null +++ b/experimental/libbox/internal/oomprofile/defs_darwin_arm64.go @@ -0,0 +1,24 @@ +//go:build darwin && arm64 + +package oomprofile + +type machVMRegionBasicInfoData struct { + Protection int32 + MaxProtection int32 + Inheritance uint32 + Shared int32 + Reserved int32 + Offset [8]byte + Behavior int32 + UserWiredCount uint16 + PadCgo1 [2]byte +} + +const ( + _VM_PROT_READ = 0x1 + _VM_PROT_EXECUTE = 0x4 + + _MACH_SEND_INVALID_DEST = 0x10000003 + + _MAXPATHLEN = 0x400 +) diff --git a/experimental/libbox/internal/oomprofile/linkname.go b/experimental/libbox/internal/oomprofile/linkname.go new file mode 100644 index 00000000..f7ab2717 --- /dev/null +++ b/experimental/libbox/internal/oomprofile/linkname.go @@ -0,0 +1,46 @@ +//go:build darwin || linux || windows + +package oomprofile + +import ( + "runtime" + _ "runtime/pprof" + "unsafe" + _ "unsafe" +) + +//go:linkname runtimeMemProfileInternal runtime.pprof_memProfileInternal +func runtimeMemProfileInternal(p []memProfileRecord, inuseZero bool) (n int, ok bool) + +//go:linkname runtimeBlockProfileInternal runtime.pprof_blockProfileInternal +func runtimeBlockProfileInternal(p []blockProfileRecord) (n int, ok bool) + +//go:linkname runtimeMutexProfileInternal runtime.pprof_mutexProfileInternal +func runtimeMutexProfileInternal(p []blockProfileRecord) (n int, ok bool) + +//go:linkname runtimeThreadCreateInternal runtime.pprof_threadCreateInternal +func runtimeThreadCreateInternal(p []stackRecord) (n int, ok bool) + +//go:linkname runtimeGoroutineProfileWithLabels runtime.pprof_goroutineProfileWithLabels +func runtimeGoroutineProfileWithLabels(p []stackRecord, labels []unsafe.Pointer) (n int, ok bool) + +//go:linkname runtimeCyclesPerSecond runtime/pprof.runtime_cyclesPerSecond +func runtimeCyclesPerSecond() int64 + +//go:linkname runtimeMakeProfStack runtime.pprof_makeProfStack +func runtimeMakeProfStack() []uintptr + +//go:linkname runtimeFrameStartLine runtime/pprof.runtime_FrameStartLine +func runtimeFrameStartLine(f *runtime.Frame) int + +//go:linkname runtimeFrameSymbolName runtime/pprof.runtime_FrameSymbolName +func runtimeFrameSymbolName(f *runtime.Frame) string + +//go:linkname runtimeExpandFinalInlineFrame runtime/pprof.runtime_expandFinalInlineFrame +func runtimeExpandFinalInlineFrame(stk []uintptr) []uintptr + +//go:linkname stdParseProcSelfMaps runtime/pprof.parseProcSelfMaps +func stdParseProcSelfMaps(data []byte, addMapping func(lo uint64, hi uint64, offset uint64, file string, buildID string)) + +//go:linkname stdELFBuildID runtime/pprof.elfBuildID +func stdELFBuildID(file string) (string, error) diff --git a/experimental/libbox/internal/oomprofile/mapping_darwin.go b/experimental/libbox/internal/oomprofile/mapping_darwin.go new file mode 100644 index 00000000..e2730005 --- /dev/null +++ b/experimental/libbox/internal/oomprofile/mapping_darwin.go @@ -0,0 +1,56 @@ +//go:build darwin + +package oomprofile + +import ( + "encoding/binary" + "os" + "unsafe" + _ "unsafe" +) + +func isExecutable(protection int32) bool { + return (protection&_VM_PROT_EXECUTE) != 0 && (protection&_VM_PROT_READ) != 0 +} + +func (b *profileBuilder) readMapping() { + if !machVMInfo(b.addMapping) { + b.addMappingEntry(0, 0, 0, "", "", true) + } +} + +func machVMInfo(addMapping func(lo uint64, hi uint64, off uint64, file string, buildID string)) bool { + added := false + addr := uint64(0x1) + for { + var regionSize uint64 + var info machVMRegionBasicInfoData + kr := machVMRegion(&addr, ®ionSize, unsafe.Pointer(&info)) + if kr != 0 { + if kr == _MACH_SEND_INVALID_DEST { + return true + } + return added + } + if isExecutable(info.Protection) { + addMapping(addr, addr+regionSize, binary.LittleEndian.Uint64(info.Offset[:]), regionFilename(addr), "") + added = true + } + addr += regionSize + } +} + +func regionFilename(address uint64) string { + buf := make([]byte, _MAXPATHLEN) + n := procRegionFilename(os.Getpid(), address, unsafe.SliceData(buf), int64(cap(buf))) + if n == 0 { + return "" + } + return string(buf[:n]) +} + +//go:linkname machVMRegion runtime/pprof.mach_vm_region +func machVMRegion(address *uint64, regionSize *uint64, info unsafe.Pointer) int32 + +//go:linkname procRegionFilename runtime/pprof.proc_regionfilename +func procRegionFilename(pid int, address uint64, buf *byte, buflen int64) int32 diff --git a/experimental/libbox/internal/oomprofile/mapping_linux.go b/experimental/libbox/internal/oomprofile/mapping_linux.go new file mode 100644 index 00000000..cc9b03a6 --- /dev/null +++ b/experimental/libbox/internal/oomprofile/mapping_linux.go @@ -0,0 +1,13 @@ +//go:build linux + +package oomprofile + +import "os" + +func (b *profileBuilder) readMapping() { + data, _ := os.ReadFile("/proc/self/maps") + stdParseProcSelfMaps(data, b.addMapping) + if len(b.mem) == 0 { + b.addMappingEntry(0, 0, 0, "", "", true) + } +} diff --git a/experimental/libbox/internal/oomprofile/mapping_windows.go b/experimental/libbox/internal/oomprofile/mapping_windows.go new file mode 100644 index 00000000..68303d89 --- /dev/null +++ b/experimental/libbox/internal/oomprofile/mapping_windows.go @@ -0,0 +1,58 @@ +//go:build windows + +package oomprofile + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func (b *profileBuilder) readMapping() { + snapshot, err := createModuleSnapshot() + if err != nil { + b.addMappingEntry(0, 0, 0, "", "", true) + return + } + defer windows.CloseHandle(snapshot) + + var module windows.ModuleEntry32 + module.Size = uint32(windows.SizeofModuleEntry32) + err = windows.Module32First(snapshot, &module) + if err != nil { + b.addMappingEntry(0, 0, 0, "", "", true) + return + } + for err == nil { + exe := windows.UTF16ToString(module.ExePath[:]) + b.addMappingEntry( + uint64(module.ModBaseAddr), + uint64(module.ModBaseAddr)+uint64(module.ModBaseSize), + 0, + exe, + peBuildID(exe), + false, + ) + err = windows.Module32Next(snapshot, &module) + } +} + +func createModuleSnapshot() (windows.Handle, error) { + for { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, uint32(windows.GetCurrentProcessId())) + var errno windows.Errno + if err != nil && errors.As(err, &errno) && errno == windows.ERROR_BAD_LENGTH { + continue + } + return snapshot, err + } +} + +func peBuildID(file string) string { + info, err := os.Stat(file) + if err != nil { + return file + } + return file + info.ModTime().String() +} diff --git a/experimental/libbox/internal/oomprofile/oomprofile.go b/experimental/libbox/internal/oomprofile/oomprofile.go new file mode 100644 index 00000000..cd0b9bec --- /dev/null +++ b/experimental/libbox/internal/oomprofile/oomprofile.go @@ -0,0 +1,383 @@ +//go:build darwin || linux || windows + +package oomprofile + +import ( + "fmt" + "io" + "math" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + "unsafe" +) + +type stackRecord struct { + Stack []uintptr +} + +type memProfileRecord struct { + AllocBytes, FreeBytes int64 + AllocObjects, FreeObjects int64 + Stack []uintptr +} + +func (r *memProfileRecord) InUseBytes() int64 { + return r.AllocBytes - r.FreeBytes +} + +func (r *memProfileRecord) InUseObjects() int64 { + return r.AllocObjects - r.FreeObjects +} + +type blockProfileRecord struct { + Count int64 + Cycles int64 + Stack []uintptr +} + +type label struct { + key string + value string +} + +type labelSet struct { + list []label +} + +type labelMap struct { + labelSet +} + +func WriteFile(destPath string, name string) (string, error) { + writer, ok := profileWriters[name] + if !ok { + return "", fmt.Errorf("unsupported profile %q", name) + } + + filePath := filepath.Join(destPath, name+".pb") + file, err := os.Create(filePath) + if err != nil { + return "", err + } + defer file.Close() + + if err := writer(file); err != nil { + _ = os.Remove(filePath) + return "", err + } + if err := file.Close(); err != nil { + _ = os.Remove(filePath) + return "", err + } + return filePath, nil +} + +var profileWriters = map[string]func(io.Writer) error{ + "allocs": writeAlloc, + "block": writeBlock, + "goroutine": writeGoroutine, + "heap": writeHeap, + "mutex": writeMutex, + "threadcreate": writeThreadCreate, +} + +func writeHeap(w io.Writer) error { + return writeHeapInternal(w, "") +} + +func writeAlloc(w io.Writer) error { + return writeHeapInternal(w, "alloc_space") +} + +func writeHeapInternal(w io.Writer, defaultSampleType string) error { + var profile []memProfileRecord + n, _ := runtimeMemProfileInternal(nil, true) + var ok bool + for { + profile = make([]memProfileRecord, n+50) + n, ok = runtimeMemProfileInternal(profile, true) + if ok { + profile = profile[:n] + break + } + } + return writeHeapProto(w, profile, int64(runtime.MemProfileRate), defaultSampleType) +} + +func writeGoroutine(w io.Writer) error { + return writeRuntimeProfile(w, "goroutine", runtimeGoroutineProfileWithLabels) +} + +func writeThreadCreate(w io.Writer) error { + return writeRuntimeProfile(w, "threadcreate", func(p []stackRecord, _ []unsafe.Pointer) (int, bool) { + return runtimeThreadCreateInternal(p) + }) +} + +func writeRuntimeProfile(w io.Writer, name string, fetch func([]stackRecord, []unsafe.Pointer) (int, bool)) error { + var profile []stackRecord + var labels []unsafe.Pointer + + n, _ := fetch(nil, nil) + var ok bool + for { + profile = make([]stackRecord, n+10) + labels = make([]unsafe.Pointer, n+10) + n, ok = fetch(profile, labels) + if ok { + profile = profile[:n] + labels = labels[:n] + break + } + } + + return writeCountProfile(w, name, &runtimeProfile{profile, labels}) +} + +func writeBlock(w io.Writer) error { + return writeCycleProfile(w, "contentions", "delay", runtimeBlockProfileInternal) +} + +func writeMutex(w io.Writer) error { + return writeCycleProfile(w, "contentions", "delay", runtimeMutexProfileInternal) +} + +func writeCycleProfile(w io.Writer, countName string, cycleName string, fetch func([]blockProfileRecord) (int, bool)) error { + var profile []blockProfileRecord + n, _ := fetch(nil) + var ok bool + for { + profile = make([]blockProfileRecord, n+50) + n, ok = fetch(profile) + if ok { + profile = profile[:n] + break + } + } + + sort.Slice(profile, func(i, j int) bool { + return profile[i].Cycles > profile[j].Cycles + }) + + builder := newProfileBuilder(w) + builder.pbValueType(tagProfile_PeriodType, countName, "count") + builder.pb.int64Opt(tagProfile_Period, 1) + builder.pbValueType(tagProfile_SampleType, countName, "count") + builder.pbValueType(tagProfile_SampleType, cycleName, "nanoseconds") + + cpuGHz := float64(runtimeCyclesPerSecond()) / 1e9 + values := []int64{0, 0} + var locs []uint64 + expandedStack := runtimeMakeProfStack() + for _, record := range profile { + values[0] = record.Count + if cpuGHz > 0 { + values[1] = int64(float64(record.Cycles) / cpuGHz) + } else { + values[1] = 0 + } + n := expandInlinedFrames(expandedStack, record.Stack) + locs = builder.appendLocsForStack(locs[:0], expandedStack[:n]) + builder.pbSample(values, locs, nil) + } + + return builder.build() +} + +type countProfile interface { + Len() int + Stack(i int) []uintptr + Label(i int) *labelMap +} + +type runtimeProfile struct { + stk []stackRecord + labels []unsafe.Pointer +} + +func (p *runtimeProfile) Len() int { + return len(p.stk) +} + +func (p *runtimeProfile) Stack(i int) []uintptr { + return p.stk[i].Stack +} + +func (p *runtimeProfile) Label(i int) *labelMap { + return (*labelMap)(p.labels[i]) +} + +func writeCountProfile(w io.Writer, name string, profile countProfile) error { + var buf strings.Builder + key := func(stk []uintptr, labels *labelMap) string { + buf.Reset() + buf.WriteByte('@') + for _, pc := range stk { + fmt.Fprintf(&buf, " %#x", pc) + } + if labels != nil { + buf.WriteString("\n# labels:") + for _, label := range labels.list { + fmt.Fprintf(&buf, " %q:%q", label.key, label.value) + } + } + return buf.String() + } + + counts := make(map[string]int) + index := make(map[string]int) + var keys []string + for i := 0; i < profile.Len(); i++ { + k := key(profile.Stack(i), profile.Label(i)) + if counts[k] == 0 { + index[k] = i + keys = append(keys, k) + } + counts[k]++ + } + + sort.Sort(&keysByCount{keys: keys, count: counts}) + + builder := newProfileBuilder(w) + builder.pbValueType(tagProfile_PeriodType, name, "count") + builder.pb.int64Opt(tagProfile_Period, 1) + builder.pbValueType(tagProfile_SampleType, name, "count") + + values := []int64{0} + var locs []uint64 + for _, k := range keys { + values[0] = int64(counts[k]) + idx := index[k] + locs = builder.appendLocsForStack(locs[:0], profile.Stack(idx)) + + var labels func() + if profile.Label(idx) != nil { + labels = func() { + for _, label := range profile.Label(idx).list { + builder.pbLabel(tagSample_Label, label.key, label.value, 0) + } + } + } + builder.pbSample(values, locs, labels) + } + + return builder.build() +} + +type keysByCount struct { + keys []string + count map[string]int +} + +func (x *keysByCount) Len() int { + return len(x.keys) +} + +func (x *keysByCount) Swap(i int, j int) { + x.keys[i], x.keys[j] = x.keys[j], x.keys[i] +} + +func (x *keysByCount) Less(i int, j int) bool { + ki, kj := x.keys[i], x.keys[j] + ci, cj := x.count[ki], x.count[kj] + if ci != cj { + return ci > cj + } + return ki < kj +} + +func expandInlinedFrames(dst []uintptr, pcs []uintptr) int { + frames := runtime.CallersFrames(pcs) + var n int + for n < len(dst) { + frame, more := frames.Next() + dst[n] = frame.PC + 1 + n++ + if !more { + break + } + } + return n +} + +func writeHeapProto(w io.Writer, profile []memProfileRecord, rate int64, defaultSampleType string) error { + builder := newProfileBuilder(w) + builder.pbValueType(tagProfile_PeriodType, "space", "bytes") + builder.pb.int64Opt(tagProfile_Period, rate) + builder.pbValueType(tagProfile_SampleType, "alloc_objects", "count") + builder.pbValueType(tagProfile_SampleType, "alloc_space", "bytes") + builder.pbValueType(tagProfile_SampleType, "inuse_objects", "count") + builder.pbValueType(tagProfile_SampleType, "inuse_space", "bytes") + if defaultSampleType != "" { + builder.pb.int64Opt(tagProfile_DefaultSampleType, builder.stringIndex(defaultSampleType)) + } + + values := []int64{0, 0, 0, 0} + var locs []uint64 + for _, record := range profile { + hideRuntime := true + for tries := 0; tries < 2; tries++ { + stk := record.Stack + if hideRuntime { + for i, addr := range stk { + if f := runtime.FuncForPC(addr); f != nil && (strings.HasPrefix(f.Name(), "runtime.") || strings.HasPrefix(f.Name(), "internal/runtime/")) { + continue + } + stk = stk[i:] + break + } + } + locs = builder.appendLocsForStack(locs[:0], stk) + if len(locs) > 0 { + break + } + hideRuntime = false + } + + values[0], values[1] = scaleHeapSample(record.AllocObjects, record.AllocBytes, rate) + values[2], values[3] = scaleHeapSample(record.InUseObjects(), record.InUseBytes(), rate) + + var blockSize int64 + if record.AllocObjects > 0 { + blockSize = record.AllocBytes / record.AllocObjects + } + builder.pbSample(values, locs, func() { + if blockSize != 0 { + builder.pbLabel(tagSample_Label, "bytes", "", blockSize) + } + }) + } + + return builder.build() +} + +func scaleHeapSample(count int64, size int64, rate int64) (int64, int64) { + if count == 0 || size == 0 { + return 0, 0 + } + if rate <= 1 { + return count, size + } + + avgSize := float64(size) / float64(count) + scale := 1 / (1 - math.Exp(-avgSize/float64(rate))) + return int64(float64(count) * scale), int64(float64(size) * scale) +} + +type profileBuilder struct { + start time.Time + w io.Writer + err error + + pb protobuf + strings []string + stringMap map[string]int + locs map[uintptr]locInfo + funcs map[string]int + mem []memMap + deck pcDeck +} diff --git a/experimental/libbox/internal/oomprofile/protobuf.go b/experimental/libbox/internal/oomprofile/protobuf.go new file mode 100644 index 00000000..ed60df21 --- /dev/null +++ b/experimental/libbox/internal/oomprofile/protobuf.go @@ -0,0 +1,120 @@ +//go:build darwin || linux || windows + +package oomprofile + +type protobuf struct { + data []byte + tmp [16]byte + nest int +} + +func (b *protobuf) varint(x uint64) { + for x >= 128 { + b.data = append(b.data, byte(x)|0x80) + x >>= 7 + } + b.data = append(b.data, byte(x)) +} + +func (b *protobuf) length(tag int, length int) { + b.varint(uint64(tag)<<3 | 2) + b.varint(uint64(length)) +} + +func (b *protobuf) uint64(tag int, x uint64) { + b.varint(uint64(tag) << 3) + b.varint(x) +} + +func (b *protobuf) uint64s(tag int, x []uint64) { + if len(x) > 2 { + n1 := len(b.data) + for _, u := range x { + b.varint(u) + } + n2 := len(b.data) + b.length(tag, n2-n1) + n3 := len(b.data) + copy(b.tmp[:], b.data[n2:n3]) + copy(b.data[n1+(n3-n2):], b.data[n1:n2]) + copy(b.data[n1:], b.tmp[:n3-n2]) + return + } + for _, u := range x { + b.uint64(tag, u) + } +} + +func (b *protobuf) uint64Opt(tag int, x uint64) { + if x == 0 { + return + } + b.uint64(tag, x) +} + +func (b *protobuf) int64(tag int, x int64) { + b.uint64(tag, uint64(x)) +} + +func (b *protobuf) int64Opt(tag int, x int64) { + if x == 0 { + return + } + b.int64(tag, x) +} + +func (b *protobuf) int64s(tag int, x []int64) { + if len(x) > 2 { + n1 := len(b.data) + for _, u := range x { + b.varint(uint64(u)) + } + n2 := len(b.data) + b.length(tag, n2-n1) + n3 := len(b.data) + copy(b.tmp[:], b.data[n2:n3]) + copy(b.data[n1+(n3-n2):], b.data[n1:n2]) + copy(b.data[n1:], b.tmp[:n3-n2]) + return + } + for _, u := range x { + b.int64(tag, u) + } +} + +func (b *protobuf) bool(tag int, x bool) { + if x { + b.uint64(tag, 1) + } else { + b.uint64(tag, 0) + } +} + +func (b *protobuf) string(tag int, x string) { + b.length(tag, len(x)) + b.data = append(b.data, x...) +} + +func (b *protobuf) strings(tag int, x []string) { + for _, s := range x { + b.string(tag, s) + } +} + +type msgOffset int + +func (b *protobuf) startMessage() msgOffset { + b.nest++ + return msgOffset(len(b.data)) +} + +func (b *protobuf) endMessage(tag int, start msgOffset) { + n1 := int(start) + n2 := len(b.data) + b.length(tag, n2-n1) + n3 := len(b.data) + copy(b.tmp[:], b.data[n2:n3]) + copy(b.data[n1+(n3-n2):], b.data[n1:n2]) + copy(b.data[n1:], b.tmp[:n3-n2]) + b.nest-- +} diff --git a/experimental/libbox/internal/procfs/procfs.go b/experimental/libbox/internal/procfs/procfs.go new file mode 100644 index 00000000..8c918a79 --- /dev/null +++ b/experimental/libbox/internal/procfs/procfs.go @@ -0,0 +1,148 @@ +package procfs + +import ( + "bufio" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "net/netip" + "os" + "strconv" + "strings" + "unsafe" + + N "github.com/sagernet/sing/common/network" +) + +var ( + netIndexOfLocal = -1 + netIndexOfUid = -1 + nativeEndian binary.ByteOrder +) + +func init() { + var x uint32 = 0x01020304 + if *(*byte)(unsafe.Pointer(&x)) == 0x01 { + nativeEndian = binary.BigEndian + } else { + nativeEndian = binary.LittleEndian + } +} + +func ResolveSocketByProcSearch(network string, source, _ netip.AddrPort) int32 { + if netIndexOfLocal < 0 || netIndexOfUid < 0 { + return -1 + } + + path := "/proc/net/" + + if network == N.NetworkTCP { + path += "tcp" + } else { + path += "udp" + } + + if source.Addr().Is6() { + path += "6" + } + + sIP := source.Addr().AsSlice() + if len(sIP) == 0 { + return -1 + } + + var bytes [2]byte + binary.BigEndian.PutUint16(bytes[:], source.Port()) + local := fmt.Sprintf("%s:%s", hex.EncodeToString(nativeEndianIP(sIP)), hex.EncodeToString(bytes[:])) + + file, err := os.Open(path) + if err != nil { + return -1 + } + + defer file.Close() + + reader := bufio.NewReader(file) + + for { + row, _, err := reader.ReadLine() + if err != nil { + return -1 + } + + fields := strings.Fields(string(row)) + + if len(fields) <= netIndexOfLocal || len(fields) <= netIndexOfUid { + continue + } + + if strings.EqualFold(local, fields[netIndexOfLocal]) { + uid, err := strconv.Atoi(fields[netIndexOfUid]) + if err != nil { + return -1 + } + + return int32(uid) + } + } +} + +func nativeEndianIP(ip net.IP) []byte { + result := make([]byte, len(ip)) + + for i := 0; i < len(ip); i += 4 { + value := binary.BigEndian.Uint32(ip[i:]) + + nativeEndian.PutUint32(result[i:], value) + } + + return result +} + +func init() { + file, err := os.Open("/proc/net/tcp") + if err != nil { + return + } + + defer file.Close() + + reader := bufio.NewReader(file) + + header, _, err := reader.ReadLine() + if err != nil { + return + } + + columns := strings.Fields(string(header)) + + var txQueue, rxQueue, tr, tmWhen bool + + for idx, col := range columns { + offset := 0 + + if txQueue && rxQueue { + offset-- + } + + if tr && tmWhen { + offset-- + } + + switch col { + case "tx_queue": + txQueue = true + case "rx_queue": + rxQueue = true + case "tr": + tr = true + case "tm->when": + tmWhen = true + case "local_address": + netIndexOfLocal = idx + offset + case "uid": + netIndexOfUid = idx + offset + } + } +} diff --git a/experimental/libbox/iterator.go b/experimental/libbox/iterator.go new file mode 100644 index 00000000..32cbbddb --- /dev/null +++ b/experimental/libbox/iterator.go @@ -0,0 +1,63 @@ +package libbox + +import "github.com/sagernet/sing/common" + +type StringIterator interface { + Len() int32 + HasNext() bool + Next() string +} + +type Int32Iterator interface { + Len() int32 + HasNext() bool + Next() int32 +} + +var _ StringIterator = (*iterator[string])(nil) + +type iterator[T any] struct { + values []T +} + +func newIterator[T any](values []T) *iterator[T] { + return &iterator[T]{values} +} + +//go:noinline +func newPtrIterator[T any](values []T) *iterator[*T] { + return &iterator[*T]{common.Map(values, func(value T) *T { return &value })} +} + +func (i *iterator[T]) Len() int32 { + return int32(len(i.values)) +} + +func (i *iterator[T]) HasNext() bool { + return len(i.values) > 0 +} + +func (i *iterator[T]) Next() T { + if len(i.values) == 0 { + return common.DefaultValue[T]() + } + nextValue := i.values[0] + i.values = i.values[1:] + return nextValue +} + +type abstractIterator[T any] interface { + Next() T + HasNext() bool +} + +func iteratorToArray[T any](iterator abstractIterator[T]) []T { + if iterator == nil { + return nil + } + var values []T + for iterator.HasNext() { + values = append(values, iterator.Next()) + } + return values +} diff --git a/experimental/libbox/link_flags_stub.go b/experimental/libbox/link_flags_stub.go new file mode 100644 index 00000000..ce3d1eb8 --- /dev/null +++ b/experimental/libbox/link_flags_stub.go @@ -0,0 +1,11 @@ +//go:build !unix + +package libbox + +import ( + "net" +) + +func linkFlags(rawFlags uint32) net.Flags { + panic("stub!") +} diff --git a/experimental/libbox/link_flags_unix.go b/experimental/libbox/link_flags_unix.go new file mode 100644 index 00000000..04f41d64 --- /dev/null +++ b/experimental/libbox/link_flags_unix.go @@ -0,0 +1,32 @@ +//go:build unix + +package libbox + +import ( + "net" + "syscall" +) + +// copied from net.linkFlags +func linkFlags(rawFlags uint32) net.Flags { + var f net.Flags + if rawFlags&syscall.IFF_UP != 0 { + f |= net.FlagUp + } + if rawFlags&syscall.IFF_RUNNING != 0 { + f |= net.FlagRunning + } + if rawFlags&syscall.IFF_BROADCAST != 0 { + f |= net.FlagBroadcast + } + if rawFlags&syscall.IFF_LOOPBACK != 0 { + f |= net.FlagLoopback + } + if rawFlags&syscall.IFF_POINTOPOINT != 0 { + f |= net.FlagPointToPoint + } + if rawFlags&syscall.IFF_MULTICAST != 0 { + f |= net.FlagMulticast + } + return f +} diff --git a/experimental/libbox/log.go b/experimental/libbox/log.go new file mode 100644 index 00000000..e275d7e6 --- /dev/null +++ b/experimental/libbox/log.go @@ -0,0 +1,165 @@ +//go:build darwin || linux || windows + +package libbox + +import ( + "archive/zip" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "time" +) + +type crashReportMetadata struct { + reportMetadata + CrashedAt string `json:"crashedAt,omitempty"` + SignalName string `json:"signalName,omitempty"` + SignalCode string `json:"signalCode,omitempty"` + ExceptionName string `json:"exceptionName,omitempty"` + ExceptionReason string `json:"exceptionReason,omitempty"` +} + +func archiveCrashReport(path string, crashReportsDir string) { + content, err := os.ReadFile(path) + if err != nil || len(content) == 0 { + return + } + + info, _ := os.Stat(path) + crashTime := time.Now().UTC() + if info != nil { + crashTime = info.ModTime().UTC() + } + + initReportDir(crashReportsDir) + destPath, err := nextAvailableReportPath(crashReportsDir, crashTime) + if err != nil { + return + } + initReportDir(destPath) + + writeReportFile(destPath, "go.log", content) + metadata := crashReportMetadata{ + reportMetadata: baseReportMetadata(), + CrashedAt: crashTime.Format(time.RFC3339), + } + writeReportMetadata(destPath, metadata) + os.Remove(path) + copyConfigSnapshot(destPath) +} + +func configSnapshotPath() string { + return filepath.Join(sBasePath, "configuration.json") +} + +func saveConfigSnapshot(configContent string) { + snapshotPath := configSnapshotPath() + os.WriteFile(snapshotPath, []byte(configContent), 0o666) + chownReport(snapshotPath) +} + +func redirectStderr(path string) error { + crashReportsDir := filepath.Join(sWorkingPath, "crash_reports") + archiveCrashReport(path, crashReportsDir) + archiveCrashReport(path+".old", crashReportsDir) + + outputFile, err := os.Create(path) + if err != nil { + return err + } + if runtime.GOOS != "android" && runtime.GOOS != "windows" { + err = outputFile.Chown(sUserID, sGroupID) + if err != nil { + outputFile.Close() + os.Remove(outputFile.Name()) + return err + } + } + + err = debug.SetCrashOutput(outputFile, debug.CrashOptions{}) + if err != nil { + outputFile.Close() + os.Remove(outputFile.Name()) + return err + } + _ = outputFile.Close() + return nil +} + +func CreateZipArchive(sourcePath string, destinationPath string) error { + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return err + } + if !sourceInfo.IsDir() { + return os.ErrInvalid + } + + destinationFile, err := os.Create(destinationPath) + if err != nil { + return err + } + defer func() { + _ = destinationFile.Close() + }() + + zipWriter := zip.NewWriter(destinationFile) + + rootName := filepath.Base(sourcePath) + err = filepath.WalkDir(sourcePath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relativePath, err := filepath.Rel(sourcePath, path) + if err != nil { + return err + } + if relativePath == "." { + return nil + } + + archivePath := filepath.ToSlash(filepath.Join(rootName, relativePath)) + if d.IsDir() { + _, err = zipWriter.Create(archivePath + "/") + return err + } + + fileInfo, err := d.Info() + if err != nil { + return err + } + header, err := zip.FileInfoHeader(fileInfo) + if err != nil { + return err + } + header.Name = archivePath + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + sourceFile, err := os.Open(path) + if err != nil { + return err + } + + _, err = io.Copy(writer, sourceFile) + closeErr := sourceFile.Close() + if err != nil { + return err + } + return closeErr + }) + if err != nil { + _ = zipWriter.Close() + return err + } + + return zipWriter.Close() +} diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go new file mode 100644 index 00000000..2deedb2e --- /dev/null +++ b/experimental/libbox/monitor.go @@ -0,0 +1,117 @@ +package libbox + +import ( + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/x/list" +) + +var ( + _ tun.DefaultInterfaceMonitor = (*platformDefaultInterfaceMonitor)(nil) + _ InterfaceUpdateListener = (*platformDefaultInterfaceMonitor)(nil) +) + +type platformDefaultInterfaceMonitor struct { + *platformInterfaceWrapper + logger logger.Logger + element *list.Element[tun.NetworkUpdateCallback] + callbacks list.List[tun.DefaultInterfaceUpdateCallback] + myInterface string +} + +func (m *platformDefaultInterfaceMonitor) Start() error { + return m.iif.StartDefaultInterfaceMonitor(m) +} + +func (m *platformDefaultInterfaceMonitor) Close() error { + return m.iif.CloseDefaultInterfaceMonitor(m) +} + +func (m *platformDefaultInterfaceMonitor) DefaultInterface() *control.Interface { + m.defaultInterfaceAccess.Lock() + defer m.defaultInterfaceAccess.Unlock() + return m.defaultInterface +} + +func (m *platformDefaultInterfaceMonitor) OverrideAndroidVPN() bool { + return false +} + +func (m *platformDefaultInterfaceMonitor) AndroidVPNEnabled() bool { + return false +} + +func (m *platformDefaultInterfaceMonitor) RegisterCallback(callback tun.DefaultInterfaceUpdateCallback) *list.Element[tun.DefaultInterfaceUpdateCallback] { + m.defaultInterfaceAccess.Lock() + defer m.defaultInterfaceAccess.Unlock() + return m.callbacks.PushBack(callback) +} + +func (m *platformDefaultInterfaceMonitor) UnregisterCallback(element *list.Element[tun.DefaultInterfaceUpdateCallback]) { + m.defaultInterfaceAccess.Lock() + defer m.defaultInterfaceAccess.Unlock() + m.callbacks.Remove(element) +} + +func (m *platformDefaultInterfaceMonitor) UpdateDefaultInterface(interfaceName string, interfaceIndex32 int32, isExpensive bool, isConstrained bool) { + if sFixAndroidStack { + done := make(chan struct{}) + go func() { + m.updateDefaultInterface(interfaceName, interfaceIndex32, isExpensive, isConstrained) + close(done) + }() + <-done + } else { + m.updateDefaultInterface(interfaceName, interfaceIndex32, isExpensive, isConstrained) + } +} + +func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName string, interfaceIndex32 int32, isExpensive bool, isConstrained bool) { + m.isExpensive = isExpensive + m.isConstrained = isConstrained + err := m.networkManager.UpdateInterfaces() + if err != nil { + m.logger.Error(E.Cause(err, "update interfaces")) + } + m.defaultInterfaceAccess.Lock() + if interfaceIndex32 == -1 { + m.defaultInterface = nil + callbacks := m.callbacks.Array() + m.defaultInterfaceAccess.Unlock() + for _, callback := range callbacks { + callback(nil, 0) + } + return + } + oldInterface := m.defaultInterface + newInterface, err := m.networkManager.InterfaceFinder().ByIndex(int(interfaceIndex32)) + if err != nil { + m.defaultInterfaceAccess.Unlock() + m.logger.Error(E.Cause(err, "find updated interface: ", interfaceName)) + return + } + m.defaultInterface = newInterface + if oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index { + m.defaultInterfaceAccess.Unlock() + return + } + callbacks := m.callbacks.Array() + m.defaultInterfaceAccess.Unlock() + for _, callback := range callbacks { + callback(newInterface, 0) + } +} + +func (m *platformDefaultInterfaceMonitor) RegisterMyInterface(interfaceName string) { + m.defaultInterfaceAccess.Lock() + defer m.defaultInterfaceAccess.Unlock() + m.myInterface = interfaceName +} + +func (m *platformDefaultInterfaceMonitor) MyInterface() string { + m.defaultInterfaceAccess.Lock() + defer m.defaultInterfaceAccess.Unlock() + return m.myInterface +} diff --git a/experimental/libbox/neighbor.go b/experimental/libbox/neighbor.go new file mode 100644 index 00000000..e38aa802 --- /dev/null +++ b/experimental/libbox/neighbor.go @@ -0,0 +1,53 @@ +package libbox + +import ( + "net" + "net/netip" +) + +type NeighborEntry struct { + Address string + MacAddress string + Hostname string +} + +type NeighborEntryIterator interface { + Next() *NeighborEntry + HasNext() bool +} + +type NeighborSubscription struct { + done chan struct{} +} + +func (s *NeighborSubscription) Close() { + close(s.done) +} + +func tableToIterator(table map[netip.Addr]net.HardwareAddr) NeighborEntryIterator { + entries := make([]*NeighborEntry, 0, len(table)) + for address, mac := range table { + entries = append(entries, &NeighborEntry{ + Address: address.String(), + MacAddress: mac.String(), + }) + } + return &neighborEntryIterator{entries} +} + +type neighborEntryIterator struct { + entries []*NeighborEntry +} + +func (i *neighborEntryIterator) HasNext() bool { + return len(i.entries) > 0 +} + +func (i *neighborEntryIterator) Next() *NeighborEntry { + if len(i.entries) == 0 { + return nil + } + entry := i.entries[0] + i.entries = i.entries[1:] + return entry +} diff --git a/experimental/libbox/neighbor_darwin.go b/experimental/libbox/neighbor_darwin.go new file mode 100644 index 00000000..d7484a69 --- /dev/null +++ b/experimental/libbox/neighbor_darwin.go @@ -0,0 +1,123 @@ +//go:build darwin + +package libbox + +import ( + "net" + "net/netip" + "os" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + xroute "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + return nil, E.Cause(err, "open route socket") + } + err = unix.SetNonblock(routeSocket, true) + if err != nil { + unix.Close(routeSocket) + return nil, E.Cause(err, "set route socket nonblock") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, routeSocket, table) + return subscription, nil +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, routeSocket int, table map[netip.Addr]net.HardwareAddr) { + routeSocketFile := os.NewFile(uintptr(routeSocket), "route") + defer routeSocketFile.Close() + buffer := buf.NewPacket() + defer buffer.Release() + for { + select { + case <-s.done: + return + default: + } + tv := unix.NsecToTimeval(int64(3 * time.Second)) + _ = unix.SetsockoptTimeval(routeSocket, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + n, err := routeSocketFile.Read(buffer.FreeBytes()) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + messages, err := xroute.ParseRIB(xroute.RIBTypeRoute, buffer.FreeBytes()[:n]) + if err != nil { + continue + } + changed := false + for _, message := range messages { + routeMessage, isRouteMessage := message.(*xroute.RouteMessage) + if !isRouteMessage { + continue + } + if routeMessage.Flags&unix.RTF_LLINFO == 0 { + continue + } + address, mac, isDelete, ok := route.ParseRouteNeighborMessage(routeMessage) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} + +func ReadBootpdLeases() NeighborEntryIterator { + leaseIPToMAC, ipToHostname, macToHostname := route.ReloadLeaseFiles([]string{"/var/db/dhcpd_leases"}) + entries := make([]*NeighborEntry, 0, len(leaseIPToMAC)) + for address, mac := range leaseIPToMAC { + entry := &NeighborEntry{ + Address: address.String(), + MacAddress: mac.String(), + } + hostname, found := ipToHostname[address] + if !found { + hostname = macToHostname[mac.String()] + } + entry.Hostname = hostname + entries = append(entries, entry) + } + return &neighborEntryIterator{entries} +} diff --git a/experimental/libbox/neighbor_linux.go b/experimental/libbox/neighbor_linux.go new file mode 100644 index 00000000..ae10bdd2 --- /dev/null +++ b/experimental/libbox/neighbor_linux.go @@ -0,0 +1,88 @@ +//go:build linux + +package libbox + +import ( + "net" + "net/netip" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + return nil, E.Cause(err, "subscribe neighbor updates") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, connection, table) + return subscription, nil +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, connection *netlink.Conn, table map[netip.Addr]net.HardwareAddr) { + defer connection.Close() + for { + select { + case <-s.done: + return + default: + } + err := connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + changed := false + for _, message := range messages { + address, mac, isDelete, ok := route.ParseNeighborMessage(message) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} diff --git a/experimental/libbox/neighbor_stub.go b/experimental/libbox/neighbor_stub.go new file mode 100644 index 00000000..d465bc7b --- /dev/null +++ b/experimental/libbox/neighbor_stub.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package libbox + +import "os" + +func SubscribeNeighborTable(_ NeighborUpdateListener) (*NeighborSubscription, error) { + return nil, os.ErrInvalid +} diff --git a/experimental/libbox/networkquality.go b/experimental/libbox/networkquality.go new file mode 100644 index 00000000..fcbe6f3a --- /dev/null +++ b/experimental/libbox/networkquality.go @@ -0,0 +1,74 @@ +package libbox + +import ( + "context" + "time" + + "github.com/sagernet/sing-box/common/networkquality" +) + +type NetworkQualityTest struct { + ctx context.Context + cancel context.CancelFunc +} + +func NewNetworkQualityTest() *NetworkQualityTest { + ctx, cancel := context.WithCancel(context.Background()) + return &NetworkQualityTest{ctx: ctx, cancel: cancel} +} + +func (t *NetworkQualityTest) Start(configURL string, serial bool, maxRuntimeSeconds int32, http3 bool, handler NetworkQualityTestHandler) { + go func() { + httpClient := networkquality.NewHTTPClient(nil) + defer httpClient.CloseIdleConnections() + + measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(nil, http3) + if err != nil { + handler.OnError(err.Error()) + return + } + + result, err := networkquality.Run(networkquality.Options{ + ConfigURL: configURL, + HTTPClient: httpClient, + NewMeasurementClient: measurementClientFactory, + Serial: serial, + MaxRuntime: time.Duration(maxRuntimeSeconds) * time.Second, + Context: t.ctx, + OnProgress: func(p networkquality.Progress) { + handler.OnProgress(&NetworkQualityProgress{ + Phase: int32(p.Phase), + DownloadCapacity: p.DownloadCapacity, + UploadCapacity: p.UploadCapacity, + DownloadRPM: p.DownloadRPM, + UploadRPM: p.UploadRPM, + IdleLatencyMs: p.IdleLatencyMs, + ElapsedMs: p.ElapsedMs, + DownloadCapacityAccuracy: int32(p.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(p.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(p.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(p.UploadRPMAccuracy), + }) + }, + }) + if err != nil { + handler.OnError(err.Error()) + return + } + handler.OnResult(&NetworkQualityResult{ + DownloadCapacity: result.DownloadCapacity, + UploadCapacity: result.UploadCapacity, + DownloadRPM: result.DownloadRPM, + UploadRPM: result.UploadRPM, + IdleLatencyMs: result.IdleLatencyMs, + DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(result.UploadRPMAccuracy), + }) + }() +} + +func (t *NetworkQualityTest) Cancel() { + t.cancel() +} diff --git a/experimental/libbox/oom_report.go b/experimental/libbox/oom_report.go new file mode 100644 index 00000000..e96c3e87 --- /dev/null +++ b/experimental/libbox/oom_report.go @@ -0,0 +1,141 @@ +//go:build darwin || linux || windows + +package libbox + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/sagernet/sing-box/experimental/libbox/internal/oomprofile" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing/common/byteformats" + "github.com/sagernet/sing/common/memory" +) + +func init() { + sOOMReporter = &oomReporter{} +} + +var oomReportProfiles = []string{ + "allocs", + "block", + "goroutine", + "heap", + "mutex", + "threadcreate", +} + +type oomReportMetadata struct { + reportMetadata + RecordedAt string `json:"recordedAt"` + MemoryUsage string `json:"memoryUsage"` + AvailableMemory string `json:"availableMemory,omitempty"` + // Heap + HeapAlloc string `json:"heapAlloc,omitempty"` + HeapObjects uint64 `json:"heapObjects,omitempty,string"` + HeapInuse string `json:"heapInuse,omitempty"` + HeapIdle string `json:"heapIdle,omitempty"` + HeapReleased string `json:"heapReleased,omitempty"` + HeapSys string `json:"heapSys,omitempty"` + // Stack + StackInuse string `json:"stackInuse,omitempty"` + StackSys string `json:"stackSys,omitempty"` + // Runtime metadata + MSpanInuse string `json:"mSpanInuse,omitempty"` + MSpanSys string `json:"mSpanSys,omitempty"` + MCacheSys string `json:"mCacheSys,omitempty"` + BuckHashSys string `json:"buckHashSys,omitempty"` + GCSys string `json:"gcSys,omitempty"` + OtherSys string `json:"otherSys,omitempty"` + Sys string `json:"sys,omitempty"` + // GC & runtime + TotalAlloc string `json:"totalAlloc,omitempty"` + NumGC uint32 `json:"numGC,omitempty,string"` + NumGoroutine int `json:"numGoroutine,omitempty,string"` + NextGC string `json:"nextGC,omitempty"` + LastGC string `json:"lastGC,omitempty"` +} + +type oomReporter struct{} + +var _ oomkiller.OOMReporter = (*oomReporter)(nil) + +func (r *oomReporter) WriteReport(memoryUsage uint64) error { + now := time.Now().UTC() + reportsDir := filepath.Join(sWorkingPath, "oom_reports") + err := os.MkdirAll(reportsDir, 0o777) + if err != nil { + return err + } + chownReport(reportsDir) + + destPath, err := nextAvailableReportPath(reportsDir, now) + if err != nil { + return err + } + err = os.MkdirAll(destPath, 0o777) + if err != nil { + return err + } + chownReport(destPath) + + for _, name := range oomReportProfiles { + writeOOMProfile(destPath, name) + } + + writeReportFile(destPath, "cmdline", []byte(strings.Join(os.Args, "\000"))) + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + metadata := oomReportMetadata{ + reportMetadata: baseReportMetadata(), + RecordedAt: now.Format(time.RFC3339), + MemoryUsage: byteformats.FormatMemoryBytes(memoryUsage), + // Heap + HeapAlloc: byteformats.FormatMemoryBytes(memStats.HeapAlloc), + HeapObjects: memStats.HeapObjects, + HeapInuse: byteformats.FormatMemoryBytes(memStats.HeapInuse), + HeapIdle: byteformats.FormatMemoryBytes(memStats.HeapIdle), + HeapReleased: byteformats.FormatMemoryBytes(memStats.HeapReleased), + HeapSys: byteformats.FormatMemoryBytes(memStats.HeapSys), + // Stack + StackInuse: byteformats.FormatMemoryBytes(memStats.StackInuse), + StackSys: byteformats.FormatMemoryBytes(memStats.StackSys), + // Runtime metadata + MSpanInuse: byteformats.FormatMemoryBytes(memStats.MSpanInuse), + MSpanSys: byteformats.FormatMemoryBytes(memStats.MSpanSys), + MCacheSys: byteformats.FormatMemoryBytes(memStats.MCacheSys), + BuckHashSys: byteformats.FormatMemoryBytes(memStats.BuckHashSys), + GCSys: byteformats.FormatMemoryBytes(memStats.GCSys), + OtherSys: byteformats.FormatMemoryBytes(memStats.OtherSys), + Sys: byteformats.FormatMemoryBytes(memStats.Sys), + // GC & runtime + TotalAlloc: byteformats.FormatMemoryBytes(memStats.TotalAlloc), + NumGC: memStats.NumGC, + NumGoroutine: runtime.NumGoroutine(), + NextGC: byteformats.FormatMemoryBytes(memStats.NextGC), + } + if memStats.LastGC > 0 { + metadata.LastGC = time.Unix(0, int64(memStats.LastGC)).UTC().Format(time.RFC3339) + } + availableMemory := memory.Available() + if availableMemory > 0 { + metadata.AvailableMemory = byteformats.FormatMemoryBytes(availableMemory) + } + writeReportMetadata(destPath, metadata) + copyConfigSnapshot(destPath) + + return nil +} + +func writeOOMProfile(destPath string, name string) { + filePath, err := oomprofile.WriteFile(destPath, name) + if err != nil { + return + } + chownReport(filePath) +} diff --git a/experimental/libbox/panic.go b/experimental/libbox/panic.go new file mode 100644 index 00000000..62fe1326 --- /dev/null +++ b/experimental/libbox/panic.go @@ -0,0 +1,12 @@ +package libbox + +// https://github.com/golang/go/issues/46893 +// TODO: remove after `bulkBarrierPreWrite: unaligned arguments` fixed + +type StringBox struct { + Value string +} + +func wrapString(value string) *StringBox { + return &StringBox{Value: value} +} diff --git a/experimental/libbox/pidfd_android.go b/experimental/libbox/pidfd_android.go new file mode 100644 index 00000000..cc7cdd7a --- /dev/null +++ b/experimental/libbox/pidfd_android.go @@ -0,0 +1,19 @@ +package libbox + +import ( + "os" + _ "unsafe" +) + +// https://github.com/SagerNet/sing-box/issues/3233 +// https://github.com/golang/go/issues/70508 +// https://github.com/tailscale/tailscale/issues/13452 + +//go:linkname checkPidfdOnce os.checkPidfdOnce +var checkPidfdOnce func() error + +func init() { + checkPidfdOnce = func() error { + return os.ErrInvalid + } +} diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go new file mode 100644 index 00000000..e65d0818 --- /dev/null +++ b/experimental/libbox/platform.go @@ -0,0 +1,141 @@ +package libbox + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +type PlatformInterface interface { + LocalDNSTransport() LocalDNSTransport + UsePlatformAutoDetectInterfaceControl() bool + AutoDetectInterfaceControl(fd int32) error + OpenTun(options TunOptions) (int32, error) + UseProcFS() bool + FindConnectionOwner(ipProtocol int32, sourceAddress string, sourcePort int32, destinationAddress string, destinationPort int32) (*ConnectionOwner, error) + StartDefaultInterfaceMonitor(listener InterfaceUpdateListener) error + CloseDefaultInterfaceMonitor(listener InterfaceUpdateListener) error + GetInterfaces() (NetworkInterfaceIterator, error) + UnderNetworkExtension() bool + IncludeAllNetworks() bool + ReadWIFIState() *WIFIState + SystemCertificates() StringIterator + ClearDNSCache() + SendNotification(notification *Notification) error + StartNeighborMonitor(listener NeighborUpdateListener) error + CloseNeighborMonitor(listener NeighborUpdateListener) error + RegisterMyInterface(name string) +} + +type NeighborUpdateListener interface { + UpdateNeighborTable(entries NeighborEntryIterator) +} + +type ConnectionOwner struct { + UserId int32 + UserName string + ProcessPath string + androidPackageNames []string +} + +func (c *ConnectionOwner) SetAndroidPackageNames(names StringIterator) { + c.androidPackageNames = iteratorToArray[string](names) +} + +func (c *ConnectionOwner) AndroidPackageNames() StringIterator { + return newIterator(c.androidPackageNames) +} + +type InterfaceUpdateListener interface { + UpdateDefaultInterface(interfaceName string, interfaceIndex int32, isExpensive bool, isConstrained bool) +} + +const ( + InterfaceTypeWIFI = int32(C.InterfaceTypeWIFI) + InterfaceTypeCellular = int32(C.InterfaceTypeCellular) + InterfaceTypeEthernet = int32(C.InterfaceTypeEthernet) + InterfaceTypeOther = int32(C.InterfaceTypeOther) +) + +type NetworkInterface struct { + Index int32 + MTU int32 + Name string + Addresses StringIterator + Flags int32 + + Type int32 + DNSServer StringIterator + Metered bool +} + +type WIFIState struct { + SSID string + BSSID string +} + +func NewWIFIState(wifiSSID string, wifiBSSID string) *WIFIState { + return &WIFIState{wifiSSID, wifiBSSID} +} + +type NetworkInterfaceIterator interface { + Next() *NetworkInterface + HasNext() bool +} + +type Notification struct { + Identifier string + TypeName string + TypeID int32 + Title string + Subtitle string + Body string + OpenURL string +} + +type OnDemandRule interface { + Target() int32 + DNSSearchDomainMatch() StringIterator + DNSServerAddressMatch() StringIterator + InterfaceTypeMatch() int32 + SSIDMatch() StringIterator + ProbeURL() string +} + +type OnDemandRuleIterator interface { + Next() OnDemandRule + HasNext() bool +} + +type onDemandRule struct { + option.OnDemandRule +} + +func (r *onDemandRule) Target() int32 { + if r.OnDemandRule.Action == nil { + return -1 + } + return int32(*r.OnDemandRule.Action) +} + +func (r *onDemandRule) DNSSearchDomainMatch() StringIterator { + return newIterator(r.OnDemandRule.DNSSearchDomainMatch) +} + +func (r *onDemandRule) DNSServerAddressMatch() StringIterator { + return newIterator(r.OnDemandRule.DNSServerAddressMatch) +} + +func (r *onDemandRule) InterfaceTypeMatch() int32 { + if r.OnDemandRule.InterfaceTypeMatch == nil { + return -1 + } + return int32(*r.OnDemandRule.InterfaceTypeMatch) +} + +func (r *onDemandRule) SSIDMatch() StringIterator { + return newIterator(r.OnDemandRule.SSIDMatch) +} + +func (r *onDemandRule) ProbeURL() string { + return r.OnDemandRule.ProbeURL +} diff --git a/experimental/libbox/pprof.go b/experimental/libbox/pprof.go new file mode 100644 index 00000000..d6d07800 --- /dev/null +++ b/experimental/libbox/pprof.go @@ -0,0 +1,33 @@ +package libbox + +import ( + "net" + "net/http" + _ "net/http/pprof" + "strconv" +) + +type PProfServer struct { + server *http.Server +} + +func NewPProfServer(port int) *PProfServer { + return &PProfServer{ + &http.Server{ + Addr: ":" + strconv.Itoa(port), + }, + } +} + +func (s *PProfServer) Start() error { + ln, err := net.Listen("tcp", s.server.Addr) + if err != nil { + return err + } + go s.server.Serve(ln) + return nil +} + +func (s *PProfServer) Close() error { + return s.server.Close() +} diff --git a/experimental/libbox/profile_import.go b/experimental/libbox/profile_import.go new file mode 100644 index 00000000..c337d015 --- /dev/null +++ b/experimental/libbox/profile_import.go @@ -0,0 +1,278 @@ +package libbox + +import ( + "bufio" + "bytes" + "compress/gzip" + "encoding/binary" + "io" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/varbin" +) + +func EncodeChunkedMessage(data []byte) []byte { + var buffer bytes.Buffer + binary.Write(&buffer, binary.BigEndian, uint16(len(data))) + buffer.Write(data) + return buffer.Bytes() +} + +func DecodeLengthChunk(data []byte) int32 { + return int32(binary.BigEndian.Uint16(data)) +} + +const ( + MessageTypeError = iota + MessageTypeProfileList + MessageTypeProfileContentRequest + MessageTypeProfileContent +) + +type ErrorMessage struct { + Message string +} + +func (e *ErrorMessage) Encode() []byte { + var buffer bytes.Buffer + buffer.WriteByte(MessageTypeError) + writeString(&buffer, e.Message) + return buffer.Bytes() +} + +func DecodeErrorMessage(data []byte) (*ErrorMessage, error) { + reader := bytes.NewReader(data) + messageType, err := reader.ReadByte() + if err != nil { + return nil, err + } + if messageType != MessageTypeError { + return nil, E.New("invalid message") + } + var message ErrorMessage + message.Message, err = readString(reader) + if err != nil { + return nil, err + } + return &message, nil +} + +const ( + ProfileTypeLocal int32 = iota + ProfileTypeiCloud + ProfileTypeRemote +) + +type ProfilePreview struct { + ProfileID int64 + Name string + Type int32 +} + +type ProfilePreviewIterator interface { + Next() *ProfilePreview + HasNext() bool +} + +type ProfileEncoder struct { + profiles []ProfilePreview +} + +func (e *ProfileEncoder) Append(profile *ProfilePreview) { + e.profiles = append(e.profiles, *profile) +} + +func (e *ProfileEncoder) Encode() []byte { + var buffer bytes.Buffer + buffer.WriteByte(MessageTypeProfileList) + binary.Write(&buffer, binary.BigEndian, uint16(len(e.profiles))) + for _, preview := range e.profiles { + binary.Write(&buffer, binary.BigEndian, preview.ProfileID) + writeString(&buffer, preview.Name) + binary.Write(&buffer, binary.BigEndian, preview.Type) + } + return buffer.Bytes() +} + +type ProfileDecoder struct { + profiles []*ProfilePreview +} + +func (d *ProfileDecoder) Decode(data []byte) error { + reader := bytes.NewReader(data) + messageType, err := reader.ReadByte() + if err != nil { + return err + } + if messageType != MessageTypeProfileList { + return E.New("invalid message") + } + var profileCount uint16 + err = binary.Read(reader, binary.BigEndian, &profileCount) + if err != nil { + return err + } + for i := 0; i < int(profileCount); i++ { + var profile ProfilePreview + err = binary.Read(reader, binary.BigEndian, &profile.ProfileID) + if err != nil { + return err + } + profile.Name, err = readString(reader) + if err != nil { + return err + } + err = binary.Read(reader, binary.BigEndian, &profile.Type) + if err != nil { + return err + } + d.profiles = append(d.profiles, &profile) + } + return nil +} + +func (d *ProfileDecoder) Iterator() ProfilePreviewIterator { + return newIterator(d.profiles) +} + +type ProfileContentRequest struct { + ProfileID int64 +} + +func (r *ProfileContentRequest) Encode() []byte { + var buffer bytes.Buffer + buffer.WriteByte(MessageTypeProfileContentRequest) + binary.Write(&buffer, binary.BigEndian, r.ProfileID) + return buffer.Bytes() +} + +func DecodeProfileContentRequest(data []byte) (*ProfileContentRequest, error) { + reader := bytes.NewReader(data) + messageType, err := reader.ReadByte() + if err != nil { + return nil, err + } + if messageType != MessageTypeProfileContentRequest { + return nil, E.New("invalid message") + } + var request ProfileContentRequest + err = binary.Read(reader, binary.BigEndian, &request.ProfileID) + if err != nil { + return nil, err + } + return &request, nil +} + +type ProfileContent struct { + Name string + Type int32 + Config string + RemotePath string + AutoUpdate bool + AutoUpdateInterval int32 + LastUpdated int64 +} + +func (c *ProfileContent) Encode() []byte { + buffer := new(bytes.Buffer) + buffer.WriteByte(MessageTypeProfileContent) + buffer.WriteByte(1) + gWriter := gzip.NewWriter(buffer) + writer := bufio.NewWriter(gWriter) + writeStringBuffered(writer, c.Name) + binary.Write(writer, binary.BigEndian, c.Type) + writeStringBuffered(writer, c.Config) + if c.Type != ProfileTypeLocal { + writeStringBuffered(writer, c.RemotePath) + } + if c.Type == ProfileTypeRemote { + binary.Write(writer, binary.BigEndian, c.AutoUpdate) + binary.Write(writer, binary.BigEndian, c.AutoUpdateInterval) + binary.Write(writer, binary.BigEndian, c.LastUpdated) + } + writer.Flush() + gWriter.Flush() + gWriter.Close() + return buffer.Bytes() +} + +func DecodeProfileContent(data []byte) (*ProfileContent, error) { + reader := bytes.NewReader(data) + messageType, err := reader.ReadByte() + if err != nil { + return nil, err + } + if messageType != MessageTypeProfileContent { + return nil, E.New("invalid message") + } + version, err := reader.ReadByte() + if err != nil { + return nil, err + } + gReader, err := gzip.NewReader(reader) + if err != nil { + return nil, E.Cause(err, "unsupported profile") + } + bReader := varbin.StubReader(gReader) + var content ProfileContent + content.Name, err = readString(bReader) + if err != nil { + return nil, err + } + err = binary.Read(bReader, binary.BigEndian, &content.Type) + if err != nil { + return nil, err + } + content.Config, err = readString(bReader) + if err != nil { + return nil, err + } + if content.Type != ProfileTypeLocal { + content.RemotePath, err = readString(bReader) + if err != nil { + return nil, err + } + } + if content.Type == ProfileTypeRemote || (version == 0 && content.Type != ProfileTypeLocal) { + err = binary.Read(bReader, binary.BigEndian, &content.AutoUpdate) + if err != nil { + return nil, err + } + if version >= 1 { + err = binary.Read(bReader, binary.BigEndian, &content.AutoUpdateInterval) + if err != nil { + return nil, err + } + } + err = binary.Read(bReader, binary.BigEndian, &content.LastUpdated) + if err != nil { + return nil, err + } + } + return &content, nil +} + +func readString(reader io.ByteReader) (string, error) { + length, err := binary.ReadUvarint(reader) + if err != nil { + return "", err + } + buf := make([]byte, length) + for i := range buf { + buf[i], err = reader.ReadByte() + if err != nil { + return "", err + } + } + return string(buf), nil +} + +func writeString(buffer *bytes.Buffer, value string) { + varbin.WriteUvarint(buffer, uint64(len(value))) + buffer.WriteString(value) +} + +func writeStringBuffered(writer *bufio.Writer, value string) { + varbin.WriteUvarint(writer, uint64(len(value))) + writer.WriteString(value) +} diff --git a/experimental/libbox/remote_profile.go b/experimental/libbox/remote_profile.go new file mode 100644 index 00000000..19c1256a --- /dev/null +++ b/experimental/libbox/remote_profile.go @@ -0,0 +1,41 @@ +package libbox + +import ( + "net/url" +) + +func GenerateRemoteProfileImportLink(name string, remoteURL string) string { + importLink := &url.URL{ + Scheme: "sing-box", + Host: "import-remote-profile", + RawQuery: url.Values{"url": []string{remoteURL}}.Encode(), + Fragment: name, + } + return importLink.String() +} + +type ImportRemoteProfile struct { + Name string + URL string + Host string +} + +func ParseRemoteProfileImportLink(importLink string) (*ImportRemoteProfile, error) { + importURL, err := url.Parse(importLink) + if err != nil { + return nil, err + } + remoteURL, err := url.Parse(importURL.Query().Get("url")) + if err != nil { + return nil, err + } + name := importURL.Fragment + if name == "" { + name = remoteURL.Host + } + return &ImportRemoteProfile{ + Name: name, + URL: remoteURL.String(), + Host: remoteURL.Host, + }, nil +} diff --git a/experimental/libbox/report.go b/experimental/libbox/report.go new file mode 100644 index 00000000..816dcac4 --- /dev/null +++ b/experimental/libbox/report.go @@ -0,0 +1,97 @@ +//go:build darwin || linux || windows + +package libbox + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strconv" + "time" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +type reportMetadata struct { + Source string `json:"source,omitempty"` + BundleIdentifier string `json:"bundleIdentifier,omitempty"` + ProcessName string `json:"processName,omitempty"` + ProcessPath string `json:"processPath,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + AppVersion string `json:"appVersion,omitempty"` + AppMarketingVersion string `json:"appMarketingVersion,omitempty"` + CoreVersion string `json:"coreVersion,omitempty"` + GoVersion string `json:"goVersion,omitempty"` +} + +func baseReportMetadata() reportMetadata { + processPath, _ := os.Executable() + processName := filepath.Base(processPath) + if processName == "." { + processName = "" + } + return reportMetadata{ + Source: sCrashReportSource, + ProcessName: processName, + ProcessPath: processPath, + CoreVersion: C.Version, + GoVersion: GoVersion(), + } +} + +func writeReportFile(destPath string, name string, content []byte) { + filePath := filepath.Join(destPath, name) + os.WriteFile(filePath, content, 0o666) + chownReport(filePath) +} + +func writeReportMetadata(destPath string, metadata any) { + data, err := json.Marshal(metadata) + if err != nil { + return + } + writeReportFile(destPath, "metadata.json", data) +} + +func copyConfigSnapshot(destPath string) { + snapshotPath := configSnapshotPath() + content, err := os.ReadFile(snapshotPath) + if err != nil { + return + } + if len(bytes.TrimSpace(content)) == 0 { + return + } + writeReportFile(destPath, "configuration.json", content) +} + +func initReportDir(path string) { + os.MkdirAll(path, 0o777) + chownReport(path) +} + +func chownReport(path string) { + if runtime.GOOS != "android" && runtime.GOOS != "windows" { + os.Chown(path, sUserID, sGroupID) + } +} + +func nextAvailableReportPath(reportsDir string, timestamp time.Time) (string, error) { + destName := timestamp.Format("2006-01-02T15-04-05") + destPath := filepath.Join(reportsDir, destName) + _, err := os.Stat(destPath) + if os.IsNotExist(err) { + return destPath, nil + } + for i := 1; i <= 1000; i++ { + suffixedPath := filepath.Join(reportsDir, destName+"-"+strconv.Itoa(i)) + _, err = os.Stat(suffixedPath) + if os.IsNotExist(err) { + return suffixedPath, nil + } + } + return "", E.New("no available report path for ", destName) +} diff --git a/experimental/libbox/semver.go b/experimental/libbox/semver.go new file mode 100644 index 00000000..b0919222 --- /dev/null +++ b/experimental/libbox/semver.go @@ -0,0 +1,27 @@ +package libbox + +import ( + "strings" + + "golang.org/x/mod/semver" +) + +func CompareSemver(left string, right string) bool { + normalizedLeft := normalizeSemver(left) + if !semver.IsValid(normalizedLeft) { + return false + } + normalizedRight := normalizeSemver(right) + if !semver.IsValid(normalizedRight) { + return false + } + return semver.Compare(normalizedLeft, normalizedRight) > 0 +} + +func normalizeSemver(version string) string { + trimmedVersion := strings.TrimSpace(version) + if strings.HasPrefix(trimmedVersion, "v") { + return trimmedVersion + } + return "v" + trimmedVersion +} diff --git a/experimental/libbox/semver_test.go b/experimental/libbox/semver_test.go new file mode 100644 index 00000000..f76093b4 --- /dev/null +++ b/experimental/libbox/semver_test.go @@ -0,0 +1,16 @@ +package libbox + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompareSemver(t *testing.T) { + t.Parallel() + + require.False(t, CompareSemver("1.13.0-rc.4", "1.13.0")) + require.True(t, CompareSemver("1.13.1", "1.13.0")) + require.False(t, CompareSemver("v1.13.0", "1.13.0")) + require.False(t, CompareSemver("1.13.0-", "1.13.0")) +} diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go new file mode 100644 index 00000000..7becf9fa --- /dev/null +++ b/experimental/libbox/service.go @@ -0,0 +1,288 @@ +package libbox + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net" + "net/netip" + "runtime" + "strconv" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/libbox/internal/procfs" + "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.PlatformInterface = (*platformInterfaceWrapper)(nil) + +type platformInterfaceWrapper struct { + iif PlatformInterface + useProcFS bool + networkManager adapter.NetworkManager + myTunName string + defaultInterfaceAccess sync.Mutex + defaultInterface *control.Interface + isExpensive bool + isConstrained bool +} + +func (w *platformInterfaceWrapper) Initialize(networkManager adapter.NetworkManager) error { + w.networkManager = networkManager + return nil +} + +func (w *platformInterfaceWrapper) UsePlatformAutoDetectInterfaceControl() bool { + return w.iif.UsePlatformAutoDetectInterfaceControl() +} + +func (w *platformInterfaceWrapper) AutoDetectInterfaceControl(fd int) error { + return w.iif.AutoDetectInterfaceControl(int32(fd)) +} + +func (w *platformInterfaceWrapper) UsePlatformInterface() bool { + return true +} + +func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { + if len(options.IncludeUID) > 0 || len(options.ExcludeUID) > 0 { + return nil, E.New("platform: unsupported uid options") + } + if len(options.IncludeAndroidUser) > 0 { + return nil, E.New("platform: unsupported android_user option") + } + routeRanges, err := options.BuildAutoRouteRanges(true) + if err != nil { + return nil, err + } + tunFd, err := w.iif.OpenTun(&tunOptions{options, routeRanges, platformOptions}) + if err != nil { + return nil, err + } + options.Name, err = getTunnelName(tunFd) + if err != nil { + return nil, E.Cause(err, "query tun name") + } + options.InterfaceMonitor.RegisterMyInterface(options.Name) + dupFd, err := dup(int(tunFd)) + if err != nil { + return nil, E.Cause(err, "dup tun file descriptor") + } + options.FileDescriptor = dupFd + w.myTunName = options.Name + w.iif.RegisterMyInterface(options.Name) + return tun.New(*options) +} + +func (w *platformInterfaceWrapper) UsePlatformDefaultInterfaceMonitor() bool { + return true +} + +func (w *platformInterfaceWrapper) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor { + return &platformDefaultInterfaceMonitor{ + platformInterfaceWrapper: w, + logger: logger, + } +} + +func (w *platformInterfaceWrapper) UsePlatformNetworkInterfaces() bool { + return true +} + +func (w *platformInterfaceWrapper) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + interfaceIterator, err := w.iif.GetInterfaces() + if err != nil { + return nil, err + } + var interfaces []adapter.NetworkInterface + for _, netInterface := range iteratorToArray[*NetworkInterface](interfaceIterator) { + if netInterface.Name == w.myTunName { + continue + } + w.defaultInterfaceAccess.Lock() + // (GOOS=windows) SA4006: this value of `isDefault` is never used + // Why not used? + //nolint:staticcheck + isDefault := w.defaultInterface != nil && int(netInterface.Index) == w.defaultInterface.Index + w.defaultInterfaceAccess.Unlock() + interfaces = append(interfaces, adapter.NetworkInterface{ + Interface: control.Interface{ + Index: int(netInterface.Index), + MTU: int(netInterface.MTU), + Name: netInterface.Name, + Addresses: common.Map(iteratorToArray[string](netInterface.Addresses), netip.MustParsePrefix), + Flags: linkFlags(uint32(netInterface.Flags)), + }, + Type: C.InterfaceType(netInterface.Type), + DNSServers: iteratorToArray[string](netInterface.DNSServer), + Expensive: netInterface.Metered || isDefault && w.isExpensive, + Constrained: isDefault && w.isConstrained, + }) + } + interfaces = common.UniqBy(interfaces, func(it adapter.NetworkInterface) string { + return it.Name + }) + return interfaces, nil +} + +func (w *platformInterfaceWrapper) UnderNetworkExtension() bool { + return w.iif.UnderNetworkExtension() +} + +func (w *platformInterfaceWrapper) NetworkExtensionIncludeAllNetworks() bool { + return w.iif.IncludeAllNetworks() +} + +func (w *platformInterfaceWrapper) ClearDNSCache() { + w.iif.ClearDNSCache() +} + +func (w *platformInterfaceWrapper) RequestPermissionForWIFIState() error { + return nil +} + +func (w *platformInterfaceWrapper) UsePlatformWIFIMonitor() bool { + return true +} + +func (w *platformInterfaceWrapper) ReadWIFIState() adapter.WIFIState { + wifiState := w.iif.ReadWIFIState() + if wifiState == nil { + return adapter.WIFIState{} + } + return (adapter.WIFIState)(*wifiState) +} + +func (w *platformInterfaceWrapper) SystemCertificates() []string { + return iteratorToArray[string](w.iif.SystemCertificates()) +} + +func (w *platformInterfaceWrapper) UsePlatformConnectionOwnerFinder() bool { + return true +} + +func (w *platformInterfaceWrapper) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + if w.useProcFS { + var source netip.AddrPort + var destination netip.AddrPort + sourceAddr, _ := netip.ParseAddr(request.SourceAddress) + source = netip.AddrPortFrom(sourceAddr, uint16(request.SourcePort)) + destAddr, _ := netip.ParseAddr(request.DestinationAddress) + destination = netip.AddrPortFrom(destAddr, uint16(request.DestinationPort)) + + var network string + switch request.IpProtocol { + case int32(syscall.IPPROTO_TCP): + network = "tcp" + case int32(syscall.IPPROTO_UDP): + network = "udp" + default: + return nil, E.New("unknown protocol: ", request.IpProtocol) + } + + uid := procfs.ResolveSocketByProcSearch(network, source, destination) + if uid == -1 { + return nil, E.New("procfs: not found") + } + return &adapter.ConnectionOwner{ + UserId: uid, + }, nil + } + + result, err := w.iif.FindConnectionOwner(request.IpProtocol, request.SourceAddress, request.SourcePort, request.DestinationAddress, request.DestinationPort) + if err != nil { + return nil, err + } + return &adapter.ConnectionOwner{ + UserId: result.UserId, + UserName: result.UserName, + ProcessPath: result.ProcessPath, + AndroidPackageNames: result.androidPackageNames, + }, nil +} + +func (w *platformInterfaceWrapper) DisableColors() bool { + return runtime.GOOS != "android" +} + +func (w *platformInterfaceWrapper) UsePlatformNotification() bool { + return true +} + +func (w *platformInterfaceWrapper) SendNotification(notification *adapter.Notification) error { + return w.iif.SendNotification((*Notification)(notification)) +} + +func (w *platformInterfaceWrapper) UsePlatformNeighborResolver() bool { + return true +} + +func (w *platformInterfaceWrapper) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return w.iif.StartNeighborMonitor(&neighborUpdateListenerWrapper{listener: listener}) +} + +func (w *platformInterfaceWrapper) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return w.iif.CloseNeighborMonitor(nil) +} + +type neighborUpdateListenerWrapper struct { + listener adapter.NeighborUpdateListener +} + +func (w *neighborUpdateListenerWrapper) UpdateNeighborTable(entries NeighborEntryIterator) { + var result []adapter.NeighborEntry + for entries.HasNext() { + entry := entries.Next() + if entry == nil { + continue + } + address, err := netip.ParseAddr(entry.Address) + if err != nil { + continue + } + macAddress, err := net.ParseMAC(entry.MacAddress) + if err != nil { + continue + } + result = append(result, adapter.NeighborEntry{ + Address: address, + MACAddress: macAddress, + Hostname: entry.Hostname, + }) + } + w.listener.UpdateNeighborTable(result) +} + +func AvailablePort(startPort int32) (int32, error) { + for port := int(startPort); ; port++ { + if port > 65535 { + return 0, E.New("no available port found") + } + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port)))) + if err != nil { + if errors.Is(err, syscall.EADDRINUSE) { + continue + } + return 0, E.Cause(err, "find available port") + } + err = listener.Close() + if err != nil { + return 0, E.Cause(err, "close listener") + } + return int32(port), nil + } +} + +func RandomHex(length int32) *StringBox { + bytes := make([]byte, length) + common.Must1(rand.Read(bytes)) + return wrapString(hex.EncodeToString(bytes)) +} diff --git a/experimental/libbox/service_other.go b/experimental/libbox/service_other.go new file mode 100644 index 00000000..9ea68335 --- /dev/null +++ b/experimental/libbox/service_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package libbox + +import "syscall" + +func dup(fd int) (nfd int, err error) { + return syscall.Dup(fd) +} diff --git a/experimental/libbox/service_windows.go b/experimental/libbox/service_windows.go new file mode 100644 index 00000000..2dc3b645 --- /dev/null +++ b/experimental/libbox/service_windows.go @@ -0,0 +1,7 @@ +package libbox + +import "os" + +func dup(fd int) (nfd int, err error) { + return 0, os.ErrInvalid +} diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go new file mode 100644 index 00000000..9f8aa03c --- /dev/null +++ b/experimental/libbox/setup.go @@ -0,0 +1,191 @@ +package libbox + +import ( + "math" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "time" + + "github.com/sagernet/sing-box/common/networkquality" + "github.com/sagernet/sing-box/common/stun" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/experimental/locale" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" +) + +var ( + sBasePath string + sWorkingPath string + sTempPath string + sUserID int + sGroupID int + sFixAndroidStack bool + sCommandServerListenPort uint16 + sCommandServerSecret string + sLogMaxLines int + sDebug bool + sCrashReportSource string + sOOMKillerEnabled bool + sOOMKillerDisabled bool + sOOMMemoryLimit int64 +) + +func init() { + debug.SetPanicOnFault(true) + debug.SetTraceback("all") +} + +type SetupOptions struct { + BasePath string + WorkingPath string + TempPath string + FixAndroidStack bool + CommandServerListenPort int32 + CommandServerSecret string + LogMaxLines int + Debug bool + CrashReportSource string + OomKillerEnabled bool + OomKillerDisabled bool + OomMemoryLimit int64 +} + +func applySetupOptions(options *SetupOptions) { + sBasePath = options.BasePath + sWorkingPath = options.WorkingPath + sTempPath = options.TempPath + + sUserID = os.Getuid() + sGroupID = os.Getgid() + + // TODO: remove after fixed + // https://github.com/golang/go/issues/68760 + sFixAndroidStack = options.FixAndroidStack + + sCommandServerListenPort = uint16(options.CommandServerListenPort) + sCommandServerSecret = options.CommandServerSecret + sLogMaxLines = options.LogMaxLines + sDebug = options.Debug + sCrashReportSource = options.CrashReportSource + ReloadSetupOptions(options) +} + +func ReloadSetupOptions(options *SetupOptions) { + sOOMKillerEnabled = options.OomKillerEnabled + sOOMKillerDisabled = options.OomKillerDisabled + sOOMMemoryLimit = options.OomMemoryLimit + if sOOMKillerEnabled { + if sOOMMemoryLimit == 0 && C.IsIos { + sOOMMemoryLimit = oomkiller.DefaultAppleNetworkExtensionMemoryLimit + } + if sOOMMemoryLimit > 0 { + debug.SetMemoryLimit(sOOMMemoryLimit * 3 / 4) + } else { + debug.SetMemoryLimit(math.MaxInt64) + } + } else { + debug.SetMemoryLimit(math.MaxInt64) + } +} + +func Setup(options *SetupOptions) error { + applySetupOptions(options) + os.MkdirAll(sWorkingPath, 0o777) + os.MkdirAll(sTempPath, 0o777) + return redirectStderr(filepath.Join(sWorkingPath, "CrashReport-"+sCrashReportSource+".log")) +} + +func SetLocale(localeId string) error { + if strings.Contains(localeId, "@") { + localeId = strings.Split(localeId, "@")[0] + } + if !locale.Set(localeId) { + return E.New("unsupported locale: ", localeId) + } + return nil +} + +func Version() string { + return C.Version +} + +func GoVersion() string { + return runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH +} + +func FormatBytes(length int64) string { + return byteformats.FormatKBytes(uint64(length)) +} + +func FormatMemoryBytes(length int64) string { + return byteformats.FormatMemoryKBytes(uint64(length)) +} + +func FormatDuration(duration int64) string { + return log.FormatDuration(time.Duration(duration) * time.Millisecond) +} + +func FormatBitrate(bps int64) string { + return networkquality.FormatBitrate(bps) +} + +const NetworkQualityDefaultConfigURL = networkquality.DefaultConfigURL + +const NetworkQualityDefaultMaxRuntimeSeconds = int32(networkquality.DefaultMaxRuntime / time.Second) + +const ( + NetworkQualityAccuracyLow = int32(networkquality.AccuracyLow) + NetworkQualityAccuracyMedium = int32(networkquality.AccuracyMedium) + NetworkQualityAccuracyHigh = int32(networkquality.AccuracyHigh) +) + +const ( + NetworkQualityPhaseIdle = int32(networkquality.PhaseIdle) + NetworkQualityPhaseDownload = int32(networkquality.PhaseDownload) + NetworkQualityPhaseUpload = int32(networkquality.PhaseUpload) + NetworkQualityPhaseDone = int32(networkquality.PhaseDone) +) + +const STUNDefaultServer = stun.DefaultServer + +const ( + STUNPhaseBinding = int32(stun.PhaseBinding) + STUNPhaseNATMapping = int32(stun.PhaseNATMapping) + STUNPhaseNATFiltering = int32(stun.PhaseNATFiltering) + STUNPhaseDone = int32(stun.PhaseDone) +) + +const ( + NATMappingEndpointIndependent = int32(stun.NATMappingEndpointIndependent) + NATMappingAddressDependent = int32(stun.NATMappingAddressDependent) + NATMappingAddressAndPortDependent = int32(stun.NATMappingAddressAndPortDependent) +) + +const ( + NATFilteringEndpointIndependent = int32(stun.NATFilteringEndpointIndependent) + NATFilteringAddressDependent = int32(stun.NATFilteringAddressDependent) + NATFilteringAddressAndPortDependent = int32(stun.NATFilteringAddressAndPortDependent) +) + +func FormatNATMapping(value int32) string { + return stun.NATMapping(value).String() +} + +func FormatNATFiltering(value int32) string { + return stun.NATFiltering(value).String() +} + +func FormatFQDN(fqdn string) string { + return dns.FqdnToDomain(fqdn) +} + +func ProxyDisplayType(proxyType string) string { + return C.ProxyDisplayName(proxyType) +} diff --git a/experimental/libbox/signal_handler_darwin.go b/experimental/libbox/signal_handler_darwin.go new file mode 100644 index 00000000..a60ddd90 --- /dev/null +++ b/experimental/libbox/signal_handler_darwin.go @@ -0,0 +1,146 @@ +//go:build darwin && badlinkname + +package libbox + +/* +#include +#include +#include + +static struct sigaction _go_sa[32]; +static struct sigaction _plcrash_sa[32]; +static int _saved = 0; + +static int _signals[] = {SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGTRAP}; +static const int _signal_count = sizeof(_signals) / sizeof(_signals[0]); + +static void _save_go_handlers(void) { + if (_saved) return; + for (int i = 0; i < _signal_count; i++) + sigaction(_signals[i], NULL, &_go_sa[_signals[i]]); + _saved = 1; +} + +static void _combined_handler(int sig, siginfo_t *info, void *uap) { + // Step 1: PLCrashReporter writes .plcrash, resets all handlers to SIG_DFL, + // and calls raise(sig) which pends (signal is blocked, no SA_NODEFER). + if ((_plcrash_sa[sig].sa_flags & SA_SIGINFO) && + (uintptr_t)_plcrash_sa[sig].sa_sigaction > 1) + _plcrash_sa[sig].sa_sigaction(sig, info, uap); + + // SIGTRAP does not rely on sigreturn -> sigpanic. Once Go's trap trampoline + // is force-installed, we can chain into it directly after PLCrashReporter. + if (sig == SIGTRAP && + (_go_sa[sig].sa_flags & SA_SIGINFO) && + (uintptr_t)_go_sa[sig].sa_sigaction > 1) { + _go_sa[sig].sa_sigaction(sig, info, uap); + return; + } + + // Step 2: Restore Go's handler via sigaction (overwrites PLCrashReporter's SIG_DFL). + // Do NOT call Go's handler directly — Go's preparePanic only modifies the + // ucontext and returns. The actual crash output is written by sigpanic, which + // only runs when the KERNEL restores the modified ucontext via sigreturn. + // A direct C function call has no sigreturn, so sigpanic would never execute. + sigaction(sig, &_go_sa[sig], NULL); + + // Step 3: Return. The kernel restores the original ucontext and re-executes + // the faulting instruction. Two signals are now pending/imminent: + // a) PLCrashReporter's raise() (SI_USER) — Go's handler ignores it + // (sighandler: sigFromUser() → return). + // b) The re-executed fault (SEGV_MAPERR) — Go's handler processes it: + // preparePanic → kernel sigreturn → sigpanic → crash output written + // via debug.SetCrashOutput. +} + +static void _reinstall_handlers(void) { + if (!_saved) return; + for (int i = 0; i < _signal_count; i++) { + int sig = _signals[i]; + struct sigaction current; + sigaction(sig, NULL, ¤t); + // Only save the handler if it's not one of ours + if (current.sa_sigaction != _combined_handler) { + // If current handler is still Go's, PLCrashReporter wasn't installed + if ((current.sa_flags & SA_SIGINFO) && + (uintptr_t)current.sa_sigaction > 1 && + current.sa_sigaction == _go_sa[sig].sa_sigaction) + memset(&_plcrash_sa[sig], 0, sizeof(_plcrash_sa[sig])); + else + _plcrash_sa[sig] = current; + } + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = _combined_handler; + sa.sa_flags = SA_SIGINFO | SA_ONSTACK; + sigemptyset(&sa.sa_mask); + sigaction(sig, &sa, NULL); + } +} +*/ +import "C" + +import ( + "reflect" + _ "unsafe" +) + +const ( + _sigtrap = 5 + _nsig = 32 +) + +//go:linkname runtimeGetsig runtime.getsig +func runtimeGetsig(i uint32) uintptr + +//go:linkname runtimeSetsig runtime.setsig +func runtimeSetsig(i uint32, fn uintptr) + +//go:linkname runtimeCgoSigtramp runtime.cgoSigtramp +func runtimeCgoSigtramp() + +//go:linkname runtimeFwdSig runtime.fwdSig +var runtimeFwdSig [_nsig]uintptr + +//go:linkname runtimeHandlingSig runtime.handlingSig +var runtimeHandlingSig [_nsig]uint32 + +func forceGoSIGTRAPHandler() { + runtimeFwdSig[_sigtrap] = runtimeGetsig(_sigtrap) + runtimeHandlingSig[_sigtrap] = 1 + runtimeSetsig(_sigtrap, reflect.ValueOf(runtimeCgoSigtramp).Pointer()) +} + +// PrepareCrashSignalHandlers captures Go's original synchronous signal handlers. +// +// In gomobile/c-archive embeddings, package init runs on the first Go entry. +// That means a native crash reporter installed before the first Go call would +// otherwise be captured as the "Go" handler and break handler restoration on +// SIGSEGV. Go skips SIGTRAP in c-archive mode, so install its trap trampoline +// before saving handlers. Call this before installing PLCrashReporter. +func PrepareCrashSignalHandlers() { + forceGoSIGTRAPHandler() + C._save_go_handlers() +} + +// ReinstallCrashSignalHandlers installs a combined signal handler that chains +// PLCrashReporter (native crash report) and Go's runtime handler (Go crash log). +// +// Call PrepareCrashSignalHandlers before installing PLCrashReporter, then call +// this after PLCrashReporter has been installed. +// +// Flow on SIGSEGV: +// 1. Combined handler calls PLCrashReporter's saved handler → .plcrash written +// 2. Combined handler restores Go's handler via sigaction +// 3. Combined handler returns — kernel re-executes faulting instruction +// 4. PLCrashReporter's pending raise() (SI_USER) is ignored by Go's handler +// 5. Hardware fault → Go's handler → preparePanic → kernel sigreturn → +// sigpanic → crash output via debug.SetCrashOutput +// +// Flow on SIGTRAP: +// 1. PrepareCrashSignalHandlers force-installs Go's cgo trap trampoline +// 2. Combined handler calls PLCrashReporter's saved handler → .plcrash written +// 3. Combined handler directly calls the saved Go trap trampoline +func ReinstallCrashSignalHandlers() { + C._reinstall_handlers() +} diff --git a/experimental/libbox/signal_handler_stub.go b/experimental/libbox/signal_handler_stub.go new file mode 100644 index 00000000..2ac68b86 --- /dev/null +++ b/experimental/libbox/signal_handler_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin || !badlinkname + +package libbox + +func PrepareCrashSignalHandlers() {} + +func ReinstallCrashSignalHandlers() {} diff --git a/experimental/libbox/stun.go b/experimental/libbox/stun.go new file mode 100644 index 00000000..3f38815d --- /dev/null +++ b/experimental/libbox/stun.go @@ -0,0 +1,50 @@ +package libbox + +import ( + "context" + + "github.com/sagernet/sing-box/common/stun" +) + +type STUNTest struct { + ctx context.Context + cancel context.CancelFunc +} + +func NewSTUNTest() *STUNTest { + ctx, cancel := context.WithCancel(context.Background()) + return &STUNTest{ctx: ctx, cancel: cancel} +} + +func (t *STUNTest) Start(server string, handler STUNTestHandler) { + go func() { + result, err := stun.Run(stun.Options{ + Server: server, + Context: t.ctx, + OnProgress: func(p stun.Progress) { + handler.OnProgress(&STUNTestProgress{ + Phase: int32(p.Phase), + ExternalAddr: p.ExternalAddr, + LatencyMs: p.LatencyMs, + NATMapping: int32(p.NATMapping), + NATFiltering: int32(p.NATFiltering), + }) + }, + }) + if err != nil { + handler.OnError(err.Error()) + return + } + handler.OnResult(&STUNTestResult{ + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NATMapping: int32(result.NATMapping), + NATFiltering: int32(result.NATFiltering), + NATTypeSupported: result.NATTypeSupported, + }) + }() +} + +func (t *STUNTest) Cancel() { + t.cancel() +} diff --git a/experimental/libbox/tun.go b/experimental/libbox/tun.go new file mode 100644 index 00000000..84c6372a --- /dev/null +++ b/experimental/libbox/tun.go @@ -0,0 +1,168 @@ +package libbox + +import ( + "net" + "net/netip" + + "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type TunOptions interface { + GetInet4Address() RoutePrefixIterator + GetInet6Address() RoutePrefixIterator + GetDNSServerAddress() (*StringBox, error) + GetMTU() int32 + GetAutoRoute() bool + GetStrictRoute() bool + GetInet4RouteAddress() RoutePrefixIterator + GetInet6RouteAddress() RoutePrefixIterator + GetInet4RouteExcludeAddress() RoutePrefixIterator + GetInet6RouteExcludeAddress() RoutePrefixIterator + GetInet4RouteRange() RoutePrefixIterator + GetInet6RouteRange() RoutePrefixIterator + GetIncludePackage() StringIterator + GetExcludePackage() StringIterator + IsHTTPProxyEnabled() bool + GetHTTPProxyServer() string + GetHTTPProxyServerPort() int32 + GetHTTPProxyBypassDomain() StringIterator + GetHTTPProxyMatchDomain() StringIterator +} + +type RoutePrefix struct { + address netip.Addr + prefix int +} + +func (p *RoutePrefix) Address() string { + return p.address.String() +} + +func (p *RoutePrefix) Prefix() int32 { + return int32(p.prefix) +} + +func (p *RoutePrefix) Mask() string { + var bits int + if p.address.Is6() { + bits = 128 + } else { + bits = 32 + } + return net.IP(net.CIDRMask(p.prefix, bits)).String() +} + +func (p *RoutePrefix) String() string { + return netip.PrefixFrom(p.address, p.prefix).String() +} + +type RoutePrefixIterator interface { + Next() *RoutePrefix + HasNext() bool +} + +func mapRoutePrefix(prefixes []netip.Prefix) RoutePrefixIterator { + return newIterator(common.Map(prefixes, func(prefix netip.Prefix) *RoutePrefix { + return &RoutePrefix{ + address: prefix.Addr(), + prefix: prefix.Bits(), + } + })) +} + +var _ TunOptions = (*tunOptions)(nil) + +type tunOptions struct { + *tun.Options + routeRanges []netip.Prefix + option.TunPlatformOptions +} + +func (o *tunOptions) GetInet4Address() RoutePrefixIterator { + return mapRoutePrefix(o.Inet4Address) +} + +func (o *tunOptions) GetInet6Address() RoutePrefixIterator { + return mapRoutePrefix(o.Inet6Address) +} + +func (o *tunOptions) GetDNSServerAddress() (*StringBox, error) { + if len(o.Inet4Address) == 0 || o.Inet4Address[0].Bits() == 32 { + return nil, E.New("need one more IPv4 address for DNS hijacking") + } + return wrapString(o.Inet4Address[0].Addr().Next().String()), nil +} + +func (o *tunOptions) GetMTU() int32 { + return int32(o.MTU) +} + +func (o *tunOptions) GetAutoRoute() bool { + return o.AutoRoute +} + +func (o *tunOptions) GetStrictRoute() bool { + return o.StrictRoute +} + +func (o *tunOptions) GetInet4RouteAddress() RoutePrefixIterator { + return mapRoutePrefix(o.Inet4RouteAddress) +} + +func (o *tunOptions) GetInet6RouteAddress() RoutePrefixIterator { + return mapRoutePrefix(o.Inet6RouteAddress) +} + +func (o *tunOptions) GetInet4RouteExcludeAddress() RoutePrefixIterator { + return mapRoutePrefix(o.Inet4RouteExcludeAddress) +} + +func (o *tunOptions) GetInet6RouteExcludeAddress() RoutePrefixIterator { + return mapRoutePrefix(o.Inet6RouteExcludeAddress) +} + +func (o *tunOptions) GetInet4RouteRange() RoutePrefixIterator { + return mapRoutePrefix(common.Filter(o.routeRanges, func(it netip.Prefix) bool { + return it.Addr().Is4() + })) +} + +func (o *tunOptions) GetInet6RouteRange() RoutePrefixIterator { + return mapRoutePrefix(common.Filter(o.routeRanges, func(it netip.Prefix) bool { + return it.Addr().Is6() + })) +} + +func (o *tunOptions) GetIncludePackage() StringIterator { + return newIterator(o.IncludePackage) +} + +func (o *tunOptions) GetExcludePackage() StringIterator { + return newIterator(o.ExcludePackage) +} + +func (o *tunOptions) IsHTTPProxyEnabled() bool { + if o.TunPlatformOptions.HTTPProxy == nil { + return false + } + return o.TunPlatformOptions.HTTPProxy.Enabled +} + +func (o *tunOptions) GetHTTPProxyServer() string { + return o.TunPlatformOptions.HTTPProxy.Server +} + +func (o *tunOptions) GetHTTPProxyServerPort() int32 { + return int32(o.TunPlatformOptions.HTTPProxy.ServerPort) +} + +func (o *tunOptions) GetHTTPProxyBypassDomain() StringIterator { + return newIterator(o.TunPlatformOptions.HTTPProxy.BypassDomain) +} + +func (o *tunOptions) GetHTTPProxyMatchDomain() StringIterator { + return newIterator(o.TunPlatformOptions.HTTPProxy.MatchDomain) +} diff --git a/experimental/libbox/tun_darwin.go b/experimental/libbox/tun_darwin.go new file mode 100644 index 00000000..e312cb91 --- /dev/null +++ b/experimental/libbox/tun_darwin.go @@ -0,0 +1,34 @@ +package libbox + +import ( + "golang.org/x/sys/unix" +) + +// kanged from wireauard-apple + +const utunControlName = "com.apple.net.utun_control" + +func GetTunnelFileDescriptor() int32 { + ctlInfo := &unix.CtlInfo{} + copy(ctlInfo.Name[:], utunControlName) + for fd := 0; fd < 1024; fd++ { + addr, err := unix.Getpeername(fd) + if err != nil { + continue + } + addrCTL, loaded := addr.(*unix.SockaddrCtl) + if !loaded { + continue + } + if ctlInfo.Id == 0 { + err = unix.IoctlCtlInfo(fd, ctlInfo) + if err != nil { + continue + } + } + if addrCTL.ID == ctlInfo.Id { + return int32(fd) + } + } + return -1 +} diff --git a/experimental/libbox/tun_name_darwin.go b/experimental/libbox/tun_name_darwin.go new file mode 100644 index 00000000..ff8c9dcc --- /dev/null +++ b/experimental/libbox/tun_name_darwin.go @@ -0,0 +1,11 @@ +package libbox + +import "golang.org/x/sys/unix" + +func getTunnelName(fd int32) (string, error) { + return unix.GetsockoptString( + int(fd), + 2, /* #define SYSPROTO_CONTROL 2 */ + 2, /* #define UTUN_OPT_IFNAME 2 */ + ) +} diff --git a/experimental/libbox/tun_name_linux.go b/experimental/libbox/tun_name_linux.go new file mode 100644 index 00000000..bf2f4f07 --- /dev/null +++ b/experimental/libbox/tun_name_linux.go @@ -0,0 +1,26 @@ +package libbox + +import ( + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +const ifReqSize = unix.IFNAMSIZ + 64 + +func getTunnelName(fd int32) (string, error) { + var ifr [ifReqSize]byte + var errno syscall.Errno + _, _, errno = unix.Syscall( + unix.SYS_IOCTL, + uintptr(fd), + uintptr(unix.TUNGETIFF), + uintptr(unsafe.Pointer(&ifr[0])), + ) + if errno != 0 { + return "", fmt.Errorf("failed to get name of TUN device: %w", errno) + } + return unix.ByteSliceToString(ifr[:]), nil +} diff --git a/experimental/libbox/tun_name_other.go b/experimental/libbox/tun_name_other.go new file mode 100644 index 00000000..94a08afa --- /dev/null +++ b/experimental/libbox/tun_name_other.go @@ -0,0 +1,9 @@ +//go:build !(darwin || linux) + +package libbox + +import "os" + +func getTunnelName(fd int32) (string, error) { + return "", os.ErrInvalid +} diff --git a/experimental/locale/locale.go b/experimental/locale/locale.go new file mode 100644 index 00000000..e5575af4 --- /dev/null +++ b/experimental/locale/locale.go @@ -0,0 +1,32 @@ +package locale + +var ( + localeRegistry = make(map[string]*Locale) + current = defaultLocal +) + +type Locale struct { + // deprecated messages for graphical clients + Locale string + DeprecatedMessage string + DeprecatedMessageNoLink string +} + +var defaultLocal = &Locale{ + Locale: "en_US", + DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s please checkout documentation for migration.", + DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.", +} + +func Current() *Locale { + return current +} + +func Set(localeId string) bool { + locale, loaded := localeRegistry[localeId] + if !loaded { + return false + } + current = locale + return true +} diff --git a/experimental/locale/locale_zh_CN.go b/experimental/locale/locale_zh_CN.go new file mode 100644 index 00000000..f5605d9d --- /dev/null +++ b/experimental/locale/locale_zh_CN.go @@ -0,0 +1,11 @@ +package locale + +var warningMessageForEndUsers = "\n\n如果您不明白此消息意味着什么:您的配置文件已过时,且将很快不可用。请联系您的配置提供者以更新配置。" + +func init() { + localeRegistry["zh_CN"] = &Locale{ + Locale: "zh_CN", + DeprecatedMessage: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除,请参阅迁移指南。" + warningMessageForEndUsers, + DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers, + } +} diff --git a/experimental/v2rayapi.go b/experimental/v2rayapi.go new file mode 100644 index 00000000..cf479631 --- /dev/null +++ b/experimental/v2rayapi.go @@ -0,0 +1,24 @@ +package experimental + +import ( + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +type V2RayServerConstructor = func(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) + +var v2rayServerConstructor V2RayServerConstructor + +func RegisterV2RayServerConstructor(constructor V2RayServerConstructor) { + v2rayServerConstructor = constructor +} + +func NewV2RayServer(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) { + if v2rayServerConstructor == nil { + return nil, os.ErrInvalid + } + return v2rayServerConstructor(logger, options) +} diff --git a/experimental/v2rayapi/server.go b/experimental/v2rayapi/server.go new file mode 100644 index 00000000..8ebae1c4 --- /dev/null +++ b/experimental/v2rayapi/server.go @@ -0,0 +1,82 @@ +package v2rayapi + +import ( + "errors" + "net" + "net/http" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func init() { + experimental.RegisterV2RayServerConstructor(NewServer) +} + +var _ adapter.V2RayServer = (*Server)(nil) + +type Server struct { + logger log.Logger + listen string + tcpListener net.Listener + grpcServer *grpc.Server + statsService *StatsService +} + +func NewServer(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) { + grpcServer := grpc.NewServer(grpc.Creds(insecure.NewCredentials())) + statsService := NewStatsService(common.PtrValueOrDefault(options.Stats)) + if statsService != nil { + RegisterStatsServiceServer(grpcServer, statsService) + } + server := &Server{ + logger: logger, + listen: options.Listen, + grpcServer: grpcServer, + statsService: statsService, + } + return server, nil +} + +func (s *Server) Name() string { + return "v2ray server" +} + +func (s *Server) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + listener, err := net.Listen("tcp", s.listen) + if err != nil { + return err + } + s.logger.Info("grpc server started at ", listener.Addr()) + s.tcpListener = listener + go func() { + err = s.grpcServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error(err) + } + }() + return nil +} + +func (s *Server) Close() error { + if s.grpcServer != nil { + s.grpcServer.Stop() + } + return common.Close( + common.PtrOrNil(s.grpcServer), + s.tcpListener, + ) +} + +func (s *Server) StatsService() adapter.ConnectionTracker { + return s.statsService +} diff --git a/experimental/v2rayapi/stats.go b/experimental/v2rayapi/stats.go new file mode 100644 index 00000000..c7b2b49f --- /dev/null +++ b/experimental/v2rayapi/stats.go @@ -0,0 +1,222 @@ +package v2rayapi + +import ( + "context" + "net" + "regexp" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" +) + +func init() { + StatsService_ServiceDesc.ServiceName = "v2ray.core.app.stats.command.StatsService" +} + +var ( + _ adapter.ConnectionTracker = (*StatsService)(nil) + _ StatsServiceServer = (*StatsService)(nil) +) + +type StatsService struct { + createdAt time.Time + inbounds map[string]bool + outbounds map[string]bool + users map[string]bool + access sync.Mutex + counters map[string]*atomic.Int64 +} + +func NewStatsService(options option.V2RayStatsServiceOptions) *StatsService { + if !options.Enabled { + return nil + } + inbounds := make(map[string]bool) + outbounds := make(map[string]bool) + users := make(map[string]bool) + for _, inbound := range options.Inbounds { + inbounds[inbound] = true + } + for _, outbound := range options.Outbounds { + outbounds[outbound] = true + } + for _, user := range options.Users { + users[user] = true + } + return &StatsService{ + createdAt: time.Now(), + inbounds: inbounds, + outbounds: outbounds, + users: users, + counters: make(map[string]*atomic.Int64), + } +} + +func (s *StatsService) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + inbound := metadata.Inbound + user := metadata.User + outbound := matchOutbound.Tag() + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + countInbound := inbound != "" && s.inbounds[inbound] + countOutbound := outbound != "" && s.outbounds[outbound] + countUser := user != "" && s.users[user] + if !countInbound && !countOutbound && !countUser { + return conn + } + s.access.Lock() + if countInbound { + readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink")) + } + if countOutbound { + readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink")) + } + if countUser { + readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink")) + } + s.access.Unlock() + return bufio.NewInt64CounterConn(conn, readCounter, writeCounter) +} + +func (s *StatsService) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn { + inbound := metadata.Inbound + user := metadata.User + outbound := matchOutbound.Tag() + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + countInbound := inbound != "" && s.inbounds[inbound] + countOutbound := outbound != "" && s.outbounds[outbound] + countUser := user != "" && s.users[user] + if !countInbound && !countOutbound && !countUser { + return conn + } + s.access.Lock() + if countInbound { + readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink")) + } + if countOutbound { + readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink")) + } + if countUser { + readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink")) + } + s.access.Unlock() + return bufio.NewInt64CounterPacketConn(conn, readCounter, nil, writeCounter, nil) +} + +func (s *StatsService) GetStats(ctx context.Context, request *GetStatsRequest) (*GetStatsResponse, error) { + s.access.Lock() + counter, loaded := s.counters[request.Name] + s.access.Unlock() + if !loaded { + return nil, E.New(request.Name, " not found.") + } + var value int64 + if request.Reset_ { + value = counter.Swap(0) + } else { + value = counter.Load() + } + return &GetStatsResponse{Stat: &Stat{Name: request.Name, Value: value}}, nil +} + +func (s *StatsService) QueryStats(ctx context.Context, request *QueryStatsRequest) (*QueryStatsResponse, error) { + var response QueryStatsResponse + s.access.Lock() + defer s.access.Unlock() + if len(request.Patterns) == 0 { + for name, counter := range s.counters { + var value int64 + if request.Reset_ { + value = counter.Swap(0) + } else { + value = counter.Load() + } + response.Stat = append(response.Stat, &Stat{Name: name, Value: value}) + } + } else if request.Regexp { + matchers := make([]*regexp.Regexp, 0, len(request.Patterns)) + for _, pattern := range request.Patterns { + matcher, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + matchers = append(matchers, matcher) + } + for name, counter := range s.counters { + for _, matcher := range matchers { + if matcher.MatchString(name) { + var value int64 + if request.Reset_ { + value = counter.Swap(0) + } else { + value = counter.Load() + } + response.Stat = append(response.Stat, &Stat{Name: name, Value: value}) + } + } + } + } else { + for name, counter := range s.counters { + for _, matcher := range request.Patterns { + if strings.Contains(name, matcher) { + var value int64 + if request.Reset_ { + value = counter.Swap(0) + } else { + value = counter.Load() + } + response.Stat = append(response.Stat, &Stat{Name: name, Value: value}) + } + } + } + } + return &response, nil +} + +func (s *StatsService) GetSysStats(ctx context.Context, request *SysStatsRequest) (*SysStatsResponse, error) { + var rtm runtime.MemStats + runtime.ReadMemStats(&rtm) + response := &SysStatsResponse{ + Uptime: uint32(time.Since(s.createdAt).Seconds()), + NumGoroutine: uint32(runtime.NumGoroutine()), + Alloc: rtm.Alloc, + TotalAlloc: rtm.TotalAlloc, + Sys: rtm.Sys, + Mallocs: rtm.Mallocs, + Frees: rtm.Frees, + LiveObjects: rtm.Mallocs - rtm.Frees, + NumGC: rtm.NumGC, + PauseTotalNs: rtm.PauseTotalNs, + } + + return response, nil +} + +func (s *StatsService) mustEmbedUnimplementedStatsServiceServer() { +} + +//nolint:staticcheck +func (s *StatsService) loadOrCreateCounter(name string) *atomic.Int64 { + counter, loaded := s.counters[name] + if loaded { + return counter + } + counter = &atomic.Int64{} + s.counters[name] = counter + return counter +} diff --git a/experimental/v2rayapi/stats.pb.go b/experimental/v2rayapi/stats.pb.go new file mode 100644 index 00000000..586b9a7f --- /dev/null +++ b/experimental/v2rayapi/stats.pb.go @@ -0,0 +1,538 @@ +package v2rayapi + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Name of the stat counter. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Whether or not to reset the counter to fetching its value. + Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatsRequest) Reset() { + *x = GetStatsRequest{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatsRequest) ProtoMessage() {} + +func (x *GetStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatsRequest.ProtoReflect.Descriptor instead. +func (*GetStatsRequest) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{0} +} + +func (x *GetStatsRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetStatsRequest) GetReset_() bool { + if x != nil { + return x.Reset_ + } + return false +} + +type Stat struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Stat) Reset() { + *x = Stat{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Stat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Stat) ProtoMessage() {} + +func (x *Stat) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Stat.ProtoReflect.Descriptor instead. +func (*Stat) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{1} +} + +func (x *Stat) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Stat) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +type GetStatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Stat *Stat `protobuf:"bytes,1,opt,name=stat,proto3" json:"stat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatsResponse) Reset() { + *x = GetStatsResponse{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatsResponse) ProtoMessage() {} + +func (x *GetStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatsResponse.ProtoReflect.Descriptor instead. +func (*GetStatsResponse) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{2} +} + +func (x *GetStatsResponse) GetStat() *Stat { + if x != nil { + return x.Stat + } + return nil +} + +type QueryStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Deprecated, use Patterns instead + Pattern string `protobuf:"bytes,1,opt,name=pattern,proto3" json:"pattern,omitempty"` + Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"` + Patterns []string `protobuf:"bytes,3,rep,name=patterns,proto3" json:"patterns,omitempty"` + Regexp bool `protobuf:"varint,4,opt,name=regexp,proto3" json:"regexp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryStatsRequest) Reset() { + *x = QueryStatsRequest{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryStatsRequest) ProtoMessage() {} + +func (x *QueryStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryStatsRequest.ProtoReflect.Descriptor instead. +func (*QueryStatsRequest) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{3} +} + +func (x *QueryStatsRequest) GetPattern() string { + if x != nil { + return x.Pattern + } + return "" +} + +func (x *QueryStatsRequest) GetReset_() bool { + if x != nil { + return x.Reset_ + } + return false +} + +func (x *QueryStatsRequest) GetPatterns() []string { + if x != nil { + return x.Patterns + } + return nil +} + +func (x *QueryStatsRequest) GetRegexp() bool { + if x != nil { + return x.Regexp + } + return false +} + +type QueryStatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Stat []*Stat `protobuf:"bytes,1,rep,name=stat,proto3" json:"stat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryStatsResponse) Reset() { + *x = QueryStatsResponse{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryStatsResponse) ProtoMessage() {} + +func (x *QueryStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryStatsResponse.ProtoReflect.Descriptor instead. +func (*QueryStatsResponse) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{4} +} + +func (x *QueryStatsResponse) GetStat() []*Stat { + if x != nil { + return x.Stat + } + return nil +} + +type SysStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SysStatsRequest) Reset() { + *x = SysStatsRequest{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SysStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SysStatsRequest) ProtoMessage() {} + +func (x *SysStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SysStatsRequest.ProtoReflect.Descriptor instead. +func (*SysStatsRequest) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{5} +} + +type SysStatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + NumGoroutine uint32 `protobuf:"varint,1,opt,name=NumGoroutine,proto3" json:"NumGoroutine,omitempty"` + NumGC uint32 `protobuf:"varint,2,opt,name=NumGC,proto3" json:"NumGC,omitempty"` + Alloc uint64 `protobuf:"varint,3,opt,name=Alloc,proto3" json:"Alloc,omitempty"` + TotalAlloc uint64 `protobuf:"varint,4,opt,name=TotalAlloc,proto3" json:"TotalAlloc,omitempty"` + Sys uint64 `protobuf:"varint,5,opt,name=Sys,proto3" json:"Sys,omitempty"` + Mallocs uint64 `protobuf:"varint,6,opt,name=Mallocs,proto3" json:"Mallocs,omitempty"` + Frees uint64 `protobuf:"varint,7,opt,name=Frees,proto3" json:"Frees,omitempty"` + LiveObjects uint64 `protobuf:"varint,8,opt,name=LiveObjects,proto3" json:"LiveObjects,omitempty"` + PauseTotalNs uint64 `protobuf:"varint,9,opt,name=PauseTotalNs,proto3" json:"PauseTotalNs,omitempty"` + Uptime uint32 `protobuf:"varint,10,opt,name=Uptime,proto3" json:"Uptime,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SysStatsResponse) Reset() { + *x = SysStatsResponse{} + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SysStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SysStatsResponse) ProtoMessage() {} + +func (x *SysStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_experimental_v2rayapi_stats_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SysStatsResponse.ProtoReflect.Descriptor instead. +func (*SysStatsResponse) Descriptor() ([]byte, []int) { + return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{6} +} + +func (x *SysStatsResponse) GetNumGoroutine() uint32 { + if x != nil { + return x.NumGoroutine + } + return 0 +} + +func (x *SysStatsResponse) GetNumGC() uint32 { + if x != nil { + return x.NumGC + } + return 0 +} + +func (x *SysStatsResponse) GetAlloc() uint64 { + if x != nil { + return x.Alloc + } + return 0 +} + +func (x *SysStatsResponse) GetTotalAlloc() uint64 { + if x != nil { + return x.TotalAlloc + } + return 0 +} + +func (x *SysStatsResponse) GetSys() uint64 { + if x != nil { + return x.Sys + } + return 0 +} + +func (x *SysStatsResponse) GetMallocs() uint64 { + if x != nil { + return x.Mallocs + } + return 0 +} + +func (x *SysStatsResponse) GetFrees() uint64 { + if x != nil { + return x.Frees + } + return 0 +} + +func (x *SysStatsResponse) GetLiveObjects() uint64 { + if x != nil { + return x.LiveObjects + } + return 0 +} + +func (x *SysStatsResponse) GetPauseTotalNs() uint64 { + if x != nil { + return x.PauseTotalNs + } + return 0 +} + +func (x *SysStatsResponse) GetUptime() uint32 { + if x != nil { + return x.Uptime + } + return 0 +} + +var File_experimental_v2rayapi_stats_proto protoreflect.FileDescriptor + +const file_experimental_v2rayapi_stats_proto_rawDesc = "" + + "\n" + + "!experimental/v2rayapi/stats.proto\x12\x15experimental.v2rayapi\";\n" + + "\x0fGetStatsRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05reset\x18\x02 \x01(\bR\x05reset\"0\n" + + "\x04Stat\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value\"C\n" + + "\x10GetStatsResponse\x12/\n" + + "\x04stat\x18\x01 \x01(\v2\x1b.experimental.v2rayapi.StatR\x04stat\"w\n" + + "\x11QueryStatsRequest\x12\x18\n" + + "\apattern\x18\x01 \x01(\tR\apattern\x12\x14\n" + + "\x05reset\x18\x02 \x01(\bR\x05reset\x12\x1a\n" + + "\bpatterns\x18\x03 \x03(\tR\bpatterns\x12\x16\n" + + "\x06regexp\x18\x04 \x01(\bR\x06regexp\"E\n" + + "\x12QueryStatsResponse\x12/\n" + + "\x04stat\x18\x01 \x03(\v2\x1b.experimental.v2rayapi.StatR\x04stat\"\x11\n" + + "\x0fSysStatsRequest\"\xa2\x02\n" + + "\x10SysStatsResponse\x12\"\n" + + "\fNumGoroutine\x18\x01 \x01(\rR\fNumGoroutine\x12\x14\n" + + "\x05NumGC\x18\x02 \x01(\rR\x05NumGC\x12\x14\n" + + "\x05Alloc\x18\x03 \x01(\x04R\x05Alloc\x12\x1e\n" + + "\n" + + "TotalAlloc\x18\x04 \x01(\x04R\n" + + "TotalAlloc\x12\x10\n" + + "\x03Sys\x18\x05 \x01(\x04R\x03Sys\x12\x18\n" + + "\aMallocs\x18\x06 \x01(\x04R\aMallocs\x12\x14\n" + + "\x05Frees\x18\a \x01(\x04R\x05Frees\x12 \n" + + "\vLiveObjects\x18\b \x01(\x04R\vLiveObjects\x12\"\n" + + "\fPauseTotalNs\x18\t \x01(\x04R\fPauseTotalNs\x12\x16\n" + + "\x06Uptime\x18\n" + + " \x01(\rR\x06Uptime2\xb4\x02\n" + + "\fStatsService\x12]\n" + + "\bGetStats\x12&.experimental.v2rayapi.GetStatsRequest\x1a'.experimental.v2rayapi.GetStatsResponse\"\x00\x12c\n" + + "\n" + + "QueryStats\x12(.experimental.v2rayapi.QueryStatsRequest\x1a).experimental.v2rayapi.QueryStatsResponse\"\x00\x12`\n" + + "\vGetSysStats\x12&.experimental.v2rayapi.SysStatsRequest\x1a'.experimental.v2rayapi.SysStatsResponse\"\x00B4Z2github.com/sagernet/sing-box/experimental/v2rayapib\x06proto3" + +var ( + file_experimental_v2rayapi_stats_proto_rawDescOnce sync.Once + file_experimental_v2rayapi_stats_proto_rawDescData []byte +) + +func file_experimental_v2rayapi_stats_proto_rawDescGZIP() []byte { + file_experimental_v2rayapi_stats_proto_rawDescOnce.Do(func() { + file_experimental_v2rayapi_stats_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_experimental_v2rayapi_stats_proto_rawDesc), len(file_experimental_v2rayapi_stats_proto_rawDesc))) + }) + return file_experimental_v2rayapi_stats_proto_rawDescData +} + +var ( + file_experimental_v2rayapi_stats_proto_msgTypes = make([]protoimpl.MessageInfo, 7) + file_experimental_v2rayapi_stats_proto_goTypes = []any{ + (*GetStatsRequest)(nil), // 0: experimental.v2rayapi.GetStatsRequest + (*Stat)(nil), // 1: experimental.v2rayapi.Stat + (*GetStatsResponse)(nil), // 2: experimental.v2rayapi.GetStatsResponse + (*QueryStatsRequest)(nil), // 3: experimental.v2rayapi.QueryStatsRequest + (*QueryStatsResponse)(nil), // 4: experimental.v2rayapi.QueryStatsResponse + (*SysStatsRequest)(nil), // 5: experimental.v2rayapi.SysStatsRequest + (*SysStatsResponse)(nil), // 6: experimental.v2rayapi.SysStatsResponse + } +) + +var file_experimental_v2rayapi_stats_proto_depIdxs = []int32{ + 1, // 0: experimental.v2rayapi.GetStatsResponse.stat:type_name -> experimental.v2rayapi.Stat + 1, // 1: experimental.v2rayapi.QueryStatsResponse.stat:type_name -> experimental.v2rayapi.Stat + 0, // 2: experimental.v2rayapi.StatsService.GetStats:input_type -> experimental.v2rayapi.GetStatsRequest + 3, // 3: experimental.v2rayapi.StatsService.QueryStats:input_type -> experimental.v2rayapi.QueryStatsRequest + 5, // 4: experimental.v2rayapi.StatsService.GetSysStats:input_type -> experimental.v2rayapi.SysStatsRequest + 2, // 5: experimental.v2rayapi.StatsService.GetStats:output_type -> experimental.v2rayapi.GetStatsResponse + 4, // 6: experimental.v2rayapi.StatsService.QueryStats:output_type -> experimental.v2rayapi.QueryStatsResponse + 6, // 7: experimental.v2rayapi.StatsService.GetSysStats:output_type -> experimental.v2rayapi.SysStatsResponse + 5, // [5:8] is the sub-list for method output_type + 2, // [2:5] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_experimental_v2rayapi_stats_proto_init() } +func file_experimental_v2rayapi_stats_proto_init() { + if File_experimental_v2rayapi_stats_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_experimental_v2rayapi_stats_proto_rawDesc), len(file_experimental_v2rayapi_stats_proto_rawDesc)), + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_experimental_v2rayapi_stats_proto_goTypes, + DependencyIndexes: file_experimental_v2rayapi_stats_proto_depIdxs, + MessageInfos: file_experimental_v2rayapi_stats_proto_msgTypes, + }.Build() + File_experimental_v2rayapi_stats_proto = out.File + file_experimental_v2rayapi_stats_proto_goTypes = nil + file_experimental_v2rayapi_stats_proto_depIdxs = nil +} diff --git a/experimental/v2rayapi/stats.proto b/experimental/v2rayapi/stats.proto new file mode 100644 index 00000000..5fc3da49 --- /dev/null +++ b/experimental/v2rayapi/stats.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package experimental.v2rayapi; +option go_package = "github.com/sagernet/sing-box/experimental/v2rayapi"; + +message GetStatsRequest { + // Name of the stat counter. + string name = 1; + // Whether or not to reset the counter to fetching its value. + bool reset = 2; +} + +message Stat { + string name = 1; + int64 value = 2; +} + +message GetStatsResponse { + Stat stat = 1; +} + +message QueryStatsRequest { + // Deprecated, use Patterns instead + string pattern = 1; + bool reset = 2; + repeated string patterns = 3; + bool regexp = 4; +} + +message QueryStatsResponse { + repeated Stat stat = 1; +} + +message SysStatsRequest {} + +message SysStatsResponse { + uint32 NumGoroutine = 1; + uint32 NumGC = 2; + uint64 Alloc = 3; + uint64 TotalAlloc = 4; + uint64 Sys = 5; + uint64 Mallocs = 6; + uint64 Frees = 7; + uint64 LiveObjects = 8; + uint64 PauseTotalNs = 9; + uint32 Uptime = 10; +} + +service StatsService { + rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {} + rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {} + rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {} +} \ No newline at end of file diff --git a/experimental/v2rayapi/stats_grpc.pb.go b/experimental/v2rayapi/stats_grpc.pb.go new file mode 100644 index 00000000..0745899f --- /dev/null +++ b/experimental/v2rayapi/stats_grpc.pb.go @@ -0,0 +1,194 @@ +package v2rayapi + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + StatsService_GetStats_FullMethodName = "/experimental.v2rayapi.StatsService/GetStats" + StatsService_QueryStats_FullMethodName = "/experimental.v2rayapi.StatsService/QueryStats" + StatsService_GetSysStats_FullMethodName = "/experimental.v2rayapi.StatsService/GetSysStats" +) + +// StatsServiceClient is the client API for StatsService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type StatsServiceClient interface { + GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) + QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error) + GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error) +} + +type statsServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewStatsServiceClient(cc grpc.ClientConnInterface) StatsServiceClient { + return &statsServiceClient{cc} +} + +func (c *statsServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetStatsResponse) + err := c.cc.Invoke(ctx, StatsService_GetStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *statsServiceClient) QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(QueryStatsResponse) + err := c.cc.Invoke(ctx, StatsService_QueryStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *statsServiceClient) GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SysStatsResponse) + err := c.cc.Invoke(ctx, StatsService_GetSysStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// StatsServiceServer is the server API for StatsService service. +// All implementations must embed UnimplementedStatsServiceServer +// for forward compatibility. +type StatsServiceServer interface { + GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) + QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error) + GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error) + mustEmbedUnimplementedStatsServiceServer() +} + +// UnimplementedStatsServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedStatsServiceServer struct{} + +func (UnimplementedStatsServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetStats not implemented") +} + +func (UnimplementedStatsServiceServer) QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method QueryStats not implemented") +} + +func (UnimplementedStatsServiceServer) GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSysStats not implemented") +} +func (UnimplementedStatsServiceServer) mustEmbedUnimplementedStatsServiceServer() {} +func (UnimplementedStatsServiceServer) testEmbeddedByValue() {} + +// UnsafeStatsServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StatsServiceServer will +// result in compilation errors. +type UnsafeStatsServiceServer interface { + mustEmbedUnimplementedStatsServiceServer() +} + +func RegisterStatsServiceServer(s grpc.ServiceRegistrar, srv StatsServiceServer) { + // If the following call panics, it indicates UnimplementedStatsServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&StatsService_ServiceDesc, srv) +} + +func _StatsService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StatsServiceServer).GetStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StatsService_GetStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StatsServiceServer).GetStats(ctx, req.(*GetStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StatsService_QueryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StatsServiceServer).QueryStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StatsService_QueryStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StatsServiceServer).QueryStats(ctx, req.(*QueryStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StatsService_GetSysStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SysStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StatsServiceServer).GetSysStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StatsService_GetSysStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StatsServiceServer).GetSysStats(ctx, req.(*SysStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// StatsService_ServiceDesc is the grpc.ServiceDesc for StatsService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StatsService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "experimental.v2rayapi.StatsService", + HandlerType: (*StatsServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetStats", + Handler: _StatsService_GetStats_Handler, + }, + { + MethodName: "QueryStats", + Handler: _StatsService_QueryStats_Handler, + }, + { + MethodName: "GetSysStats", + Handler: _StatsService_GetSysStats_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "experimental/v2rayapi/stats.proto", +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..4a9c656a --- /dev/null +++ b/go.mod @@ -0,0 +1,173 @@ +module github.com/sagernet/sing-box + +go 1.24.7 + +require ( + github.com/anthropics/anthropic-sdk-go v1.26.0 + github.com/anytls/sing-anytls v0.0.11 + github.com/caddyserver/certmagic v0.25.2 + github.com/caddyserver/zerossl v0.1.5 + github.com/coder/websocket v1.8.14 + github.com/cretz/bine v0.2.0 + github.com/database64128/tfo-go/v2 v2.3.2 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/render v1.0.3 + github.com/godbus/dbus/v5 v5.2.2 + github.com/gofrs/uuid/v5 v5.4.0 + github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 + github.com/jsimonetti/rtnetlink v1.4.0 + github.com/keybase/go-keychain v0.0.1 + github.com/libdns/acmedns v0.5.0 + github.com/libdns/alidns v1.0.6 + github.com/libdns/cloudflare v0.2.2 + github.com/libdns/libdns v1.1.1 + github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/mdlayher/netlink v1.9.0 + github.com/metacubex/utls v1.8.4 + github.com/mholt/acmez/v3 v3.1.6 + github.com/miekg/dns v1.1.72 + github.com/openai/openai-go/v3 v3.26.0 + github.com/oschwald/maxminddb-golang v1.13.1 + github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 + github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a + github.com/sagernet/cors v1.2.1 + github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa + github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa + github.com/sagernet/fswatch v0.1.1 + github.com/sagernet/gomobile v0.1.12 + github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 + github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 + github.com/sagernet/sing v0.8.5-0.20260414061014-3597f84f897c + github.com/sagernet/sing-cloudflared v0.0.0-20260407120610-7715dc2523fa + github.com/sagernet/sing-mux v0.3.4 + github.com/sagernet/sing-quic v0.6.2-0.20260330152607-bf674c163212 + github.com/sagernet/sing-shadowsocks v0.2.8 + github.com/sagernet/sing-shadowsocks2 v0.2.1 + github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 + github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695 + github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 + github.com/sagernet/smux v1.5.50-sing-box-mod.1 + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 + github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c + github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/vishvananda/netns v0.0.5 + go.uber.org/zap v1.27.1 + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba + golang.org/x/crypto v0.48.0 + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 + golang.org/x/mod v0.33.0 + golang.org/x/net v0.50.0 + golang.org/x/sys v0.41.0 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 + google.golang.org/grpc v1.79.1 + google.golang.org/protobuf v1.36.11 + howett.net/plist v1.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/ajg/form v1.5.1 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/database64128/netx-go v0.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect + github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect + github.com/sagernet/nftables v0.3.0-beta.4 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.42.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/blake3 v1.3.0 // indirect + zombiezen.com/go/capnproto2 v2.18.2+incompatible // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..9f224685 --- /dev/null +++ b/go.sum @@ -0,0 +1,415 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= +github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= +github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= +github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= +github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= +github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= +github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= +github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= +github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= +github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= +github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= +github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= +github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= +github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= +github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= +github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= +github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 h1:qi+ijeREa0yfAaO+NOcZ81gv4uzOfALUIdhkiIFvmG4= +github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= +github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= +github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= +github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa h1:7SehNSF1UHbLZa5dk+1rW1aperffJzl5r6TCJIXtAaY= +github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa h1:ijk5v9N/akiMgqu734yMpv7Pk9F4Qmjh8Vfdcb4uJHE= +github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa/go.mod h1:+FENo4+0AOvH9e3oY6/iO7yy7USNt61dgbnI5W0TDZ0= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b h1:O+PkYT88ayVWESX5tqxeMeS9OnzC3ZTic8gYiPJNXT8= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:o0MsgbsJwYkbqlbfaCvmAwb8/LAXeoSP8NE/aNvR/yY= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b h1:JEQnc7cRMUahWJFtWY6n0hs1LE0KgyRv3pD0RWS8Yo8= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:69+AKzuUW9hzw2nU79c2DWfuzrIZ3PJm1KAwXh+7xr0= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:jp9FHUVTCJQ67Ecw3Inoct6/z1VTFXPtNYpXt47pa4E= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WN3DZoECd2UbhmYQGpOA4jx4QBXiZuN1DvL/35NT61g= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:H4RKicwrIa4PwTXZOmXOg85hiCrpeFja4daOlX180pE= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:Rwi+Cu+Hgwj28F1lh837gGqSqn7oU8+r5i3UJyLPkKc= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:v2wcnPX3gt0PngFYXjXYAiarFckwx3pVAP6ETSpbSWE= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b h1:Bl0zZ3QZq6pPJMbQlYHDhhaGngVefRlFzxWc0p48eHo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b h1:vf+MbGv6RvvmXUNvganykBOnDIVXxy8XgtKOOqOcxtE= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:2IAc1bVFYF+B6hof34ChQKVhw7LElBxEEx7S0n+7o78= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b h1:NrJaiOS0VLmWTbUHhXDsLTqelmCW4y3xJqptPs4Sx0s= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b h1:A+ubSkca1nl2cT8pYUqCo1O7M41suNrKpWhZKCM/aIQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WrhGH5FDXlCAoXwN6N44yCMvy6EbIurmTmptkz3mmms= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b h1:kgwB5p5e0gdVX5iYRE7VbZS/On4qnb4UKonkGPwhkDI= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b h1:Z3dOeFlRIOeQhSh+mCYDHui1yR3S/Uw8eupczzBvxqw= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b h1:LPi6jz1k11Q67hm3Pw6aaPJ/Z6e3VtNhzrRjr5/5AQo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b h1:55sqihyfXWN7y7p7gOEgtUz9cm1mV3SDQ90/v6ROFaA= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b h1:OTA1cbv5YIDVsYA8AAXHC4NgEc7b6pDiY+edujLWfJU= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b h1:B/rdD/1A+RgqUYUZcoGhLeMqijnBd1mUt8+5LhOH7j8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b h1:QFRWi6FucrODS4xQ8e9GYIzGSeMFO/DAMtTCVeJiCvM= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b h1:2WJjPKZHLNIB4D17c3o9S+SP9kb3Qh0D26oWlun1+pE= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b h1:cUNTe4gNncRpYL28jzQf6qcJej40zzGQsH0o6CLUGws= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:+sc1LJF0FjU2hVO5xBqqT+8qzoU08J2uHwxSle2m/Hw= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:+D/uhFxllI/KTLpeNEl8dwF3omPGmUFbrqt5tJkAyp0= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:nSUzzTUAZdqjGGckayk64sz+F0TGJPHvauTiAn27UKk= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:PE/fYBiHzB52gnQMg0soBfQyJCzmWHti48kCe2TBt9w= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:hy/3lPV11pKAAojDFnb95l9NpwOym6kME7FxS9p8sXs= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= +github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= +github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg= +github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY= +github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o= +github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= +github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= +github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= +github.com/sagernet/sing v0.8.5-0.20260414061014-3597f84f897c h1:FsN+aA8CwkwlthCGZl0/vA1PIE+xV+CjVBYPm6h45Gg= +github.com/sagernet/sing v0.8.5-0.20260414061014-3597f84f897c/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing-cloudflared v0.0.0-20260407120610-7715dc2523fa h1:165HiOfgfofJIirEp1NGSmsoJAi+++WhR29IhtAu4A4= +github.com/sagernet/sing-cloudflared v0.0.0-20260407120610-7715dc2523fa/go.mod h1:bH2NKX+NpDTY1Zkxfboxw6MXB/ZywaNLmrDJYgKMJ2Y= +github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= +github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-quic v0.6.2-0.20260330152607-bf674c163212 h1:7mFOUqy+DyOj7qKGd1X54UMXbnbJiiMileK/tn17xYc= +github.com/sagernet/sing-quic v0.6.2-0.20260330152607-bf674c163212/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= +github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= +github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= +github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= +github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695 h1:2maqN3XuorEo5faXHIyYZQZ1/ybim4hImfCEWZwdPbk= +github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= +github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= +github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 h1:8zc1Aph1+ElqF9/7aSPkO0o4vTd+AfQC+CO324mLWGg= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:f9cXNB+IOOPnR8DOLMTpr42jf7naxh5Un5Y09BBf5Cg= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= +lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +zombiezen.com/go/capnproto2 v2.18.2+incompatible h1:v3BD1zbruvffn7zjJUU5Pn8nZAB11bhZSQC4W+YnnKo= +zombiezen.com/go/capnproto2 v2.18.2+incompatible/go.mod h1:XO5Pr2SbXgqZwn0m0Ru54QBqpOf4K5AYBO+8LAOBQEQ= diff --git a/include/acme.go b/include/acme.go new file mode 100644 index 00000000..093fd508 --- /dev/null +++ b/include/acme.go @@ -0,0 +1,12 @@ +//go:build with_acme + +package include + +import ( + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/service/acme" +) + +func registerACMECertificateProvider(registry *certificate.Registry) { + acme.RegisterCertificateProvider(registry) +} diff --git a/include/acme_stub.go b/include/acme_stub.go new file mode 100644 index 00000000..bceab3d7 --- /dev/null +++ b/include/acme_stub.go @@ -0,0 +1,20 @@ +//go:build !with_acme + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerACMECertificateProvider(registry *certificate.Registry) { + certificate.Register[option.ACMECertificateProviderOptions](registry, C.TypeACME, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMECertificateProviderOptions) (adapter.CertificateProviderService, error) { + return nil, E.New(`ACME is not included in this build, rebuild with -tags with_acme`) + }) +} diff --git a/include/ccm.go b/include/ccm.go new file mode 100644 index 00000000..a7520148 --- /dev/null +++ b/include/ccm.go @@ -0,0 +1,12 @@ +//go:build with_ccm && (!darwin || cgo) + +package include + +import ( + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/service/ccm" +) + +func registerCCMService(registry *service.Registry) { + ccm.RegisterService(registry) +} diff --git a/include/ccm_stub.go b/include/ccm_stub.go new file mode 100644 index 00000000..eac29eeb --- /dev/null +++ b/include/ccm_stub.go @@ -0,0 +1,20 @@ +//go:build !with_ccm + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerCCMService(registry *service.Registry) { + service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) { + return nil, E.New(`CCM is not included in this build, rebuild with -tags with_CCM`) + }) +} diff --git a/include/ccm_stub_darwin.go b/include/ccm_stub_darwin.go new file mode 100644 index 00000000..f2ad7381 --- /dev/null +++ b/include/ccm_stub_darwin.go @@ -0,0 +1,20 @@ +//go:build with_ccm && darwin && !cgo + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerCCMService(registry *service.Registry) { + service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) { + return nil, E.New(`CCM requires CGO on darwin, rebuild with CGO_ENABLED=1`) + }) +} diff --git a/include/clashapi.go b/include/clashapi.go new file mode 100644 index 00000000..550a5db6 --- /dev/null +++ b/include/clashapi.go @@ -0,0 +1,5 @@ +//go:build with_clash_api + +package include + +import _ "github.com/sagernet/sing-box/experimental/clashapi" diff --git a/include/clashapi_stub.go b/include/clashapi_stub.go new file mode 100644 index 00000000..e7d5304f --- /dev/null +++ b/include/clashapi_stub.go @@ -0,0 +1,19 @@ +//go:build !with_clash_api + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func init() { + experimental.RegisterClashServerConstructor(func(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { + return nil, E.New(`clash api is not included in this build, rebuild with -tags with_clash_api`) + }) +} diff --git a/include/cloudflared.go b/include/cloudflared.go new file mode 100644 index 00000000..63200108 --- /dev/null +++ b/include/cloudflared.go @@ -0,0 +1,12 @@ +//go:build with_cloudflared + +package include + +import ( + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/protocol/cloudflare" +) + +func registerCloudflaredInbound(registry *inbound.Registry) { + cloudflare.RegisterInbound(registry) +} diff --git a/include/cloudflared_stub.go b/include/cloudflared_stub.go new file mode 100644 index 00000000..8f49aecc --- /dev/null +++ b/include/cloudflared_stub.go @@ -0,0 +1,20 @@ +//go:build !with_cloudflared + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerCloudflaredInbound(registry *inbound.Registry) { + inbound.Register[option.CloudflaredInboundOptions](registry, C.TypeCloudflared, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CloudflaredInboundOptions) (adapter.Inbound, error) { + return nil, E.New(`Cloudflared is not included in this build, rebuild with -tags with_cloudflared`) + }) +} diff --git a/include/dhcp.go b/include/dhcp.go new file mode 100644 index 00000000..8cf074be --- /dev/null +++ b/include/dhcp.go @@ -0,0 +1,12 @@ +//go:build with_dhcp + +package include + +import ( + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport/dhcp" +) + +func registerDHCPTransport(registry *dns.TransportRegistry) { + dhcp.RegisterTransport(registry) +} diff --git a/include/dhcp_stub.go b/include/dhcp_stub.go new file mode 100644 index 00000000..272f313a --- /dev/null +++ b/include/dhcp_stub.go @@ -0,0 +1,20 @@ +//go:build !with_dhcp + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerDHCPTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.DHCPDNSServerOptions](registry, C.DNSTypeDHCP, func(ctx context.Context, logger log.ContextLogger, tag string, options option.DHCPDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`DHCP is not included in this build, rebuild with -tags with_dhcp`) + }) +} diff --git a/include/naive_outbound.go b/include/naive_outbound.go new file mode 100644 index 00000000..d15d0450 --- /dev/null +++ b/include/naive_outbound.go @@ -0,0 +1,12 @@ +//go:build with_naive_outbound + +package include + +import ( + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/naive" +) + +func registerNaiveOutbound(registry *outbound.Registry) { + naive.RegisterOutbound(registry) +} diff --git a/include/naive_outbound_stub.go b/include/naive_outbound_stub.go new file mode 100644 index 00000000..cf892091 --- /dev/null +++ b/include/naive_outbound_stub.go @@ -0,0 +1,20 @@ +//go:build !with_naive_outbound + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerNaiveOutbound(registry *outbound.Registry) { + outbound.Register[option.NaiveOutboundOptions](registry, C.TypeNaive, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`naive outbound is not included in this build, rebuild with -tags with_naive_outbound`) + }) +} diff --git a/include/ocm.go b/include/ocm.go new file mode 100644 index 00000000..cdea9eea --- /dev/null +++ b/include/ocm.go @@ -0,0 +1,12 @@ +//go:build with_ocm + +package include + +import ( + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/service/ocm" +) + +func registerOCMService(registry *service.Registry) { + ocm.RegisterService(registry) +} diff --git a/include/ocm_stub.go b/include/ocm_stub.go new file mode 100644 index 00000000..d5a94fcb --- /dev/null +++ b/include/ocm_stub.go @@ -0,0 +1,20 @@ +//go:build !with_ocm + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerOCMService(registry *service.Registry) { + service.Register[option.OCMServiceOptions](registry, C.TypeOCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.OCMServiceOptions) (adapter.Service, error) { + return nil, E.New(`OCM is not included in this build, rebuild with -tags with_ocm`) + }) +} diff --git a/include/oom_killer.go b/include/oom_killer.go new file mode 100644 index 00000000..3f70d9d0 --- /dev/null +++ b/include/oom_killer.go @@ -0,0 +1,10 @@ +package include + +import ( + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/service/oomkiller" +) + +func registerOOMKillerService(registry *service.Registry) { + oomkiller.RegisterService(registry) +} diff --git a/include/quic.go b/include/quic.go new file mode 100644 index 00000000..6a3f3017 --- /dev/null +++ b/include/quic.go @@ -0,0 +1,32 @@ +//go:build with_quic + +package include + +import ( + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport/quic" + "github.com/sagernet/sing-box/protocol/hysteria" + "github.com/sagernet/sing-box/protocol/hysteria2" + _ "github.com/sagernet/sing-box/protocol/naive/quic" + "github.com/sagernet/sing-box/protocol/tuic" + _ "github.com/sagernet/sing-box/transport/v2rayquic" +) + +func registerQUICInbounds(registry *inbound.Registry) { + hysteria.RegisterInbound(registry) + tuic.RegisterInbound(registry) + hysteria2.RegisterInbound(registry) +} + +func registerQUICOutbounds(registry *outbound.Registry) { + hysteria.RegisterOutbound(registry) + tuic.RegisterOutbound(registry) + hysteria2.RegisterOutbound(registry) +} + +func registerQUICTransports(registry *dns.TransportRegistry) { + quic.RegisterTransport(registry) + quic.RegisterHTTP3Transport(registry) +} diff --git a/include/quic_stub.go b/include/quic_stub.go new file mode 100644 index 00000000..d2c03b98 --- /dev/null +++ b/include/quic_stub.go @@ -0,0 +1,71 @@ +//go:build !with_quic + +package include + +import ( + "context" + "io" + "net/http" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func init() { + v2ray.RegisterQUICConstructor( + func(ctx context.Context, logger logger.ContextLogger, options option.V2RayQUICOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) { + return nil, C.ErrQUICNotIncluded + }, + func(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + return nil, C.ErrQUICNotIncluded + }, + ) +} + +func registerQUICInbounds(registry *inbound.Registry) { + inbound.Register[option.HysteriaInboundOptions](registry, C.TypeHysteria, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaInboundOptions) (adapter.Inbound, error) { + return nil, C.ErrQUICNotIncluded + }) + inbound.Register[option.TUICInboundOptions](registry, C.TypeTUIC, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICInboundOptions) (adapter.Inbound, error) { + return nil, C.ErrQUICNotIncluded + }) + inbound.Register[option.Hysteria2InboundOptions](registry, C.TypeHysteria2, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2InboundOptions) (adapter.Inbound, error) { + return nil, C.ErrQUICNotIncluded + }) + naive.ConfigureHTTP3ListenerFunc = func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.NaiveInboundOptions) (io.Closer, error) { + return nil, C.ErrQUICNotIncluded + } +} + +func registerQUICOutbounds(registry *outbound.Registry) { + outbound.Register[option.HysteriaOutboundOptions](registry, C.TypeHysteria, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaOutboundOptions) (adapter.Outbound, error) { + return nil, C.ErrQUICNotIncluded + }) + outbound.Register[option.TUICOutboundOptions](registry, C.TypeTUIC, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICOutboundOptions) (adapter.Outbound, error) { + return nil, C.ErrQUICNotIncluded + }) + outbound.Register[option.Hysteria2OutboundOptions](registry, C.TypeHysteria2, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2OutboundOptions) (adapter.Outbound, error) { + return nil, C.ErrQUICNotIncluded + }) +} + +func registerQUICTransports(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.RemoteTLSDNSServerOptions](registry, C.DNSTypeQUIC, func(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { + return nil, C.ErrQUICNotIncluded + }) + dns.RegisterTransport[option.RemoteHTTPSDNSServerOptions](registry, C.DNSTypeHTTP3, func(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteHTTPSDNSServerOptions) (adapter.DNSTransport, error) { + return nil, C.ErrQUICNotIncluded + }) +} diff --git a/include/registry.go b/include/registry.go new file mode 100644 index 00000000..5a1a2f97 --- /dev/null +++ b/include/registry.go @@ -0,0 +1,168 @@ +package include + +import ( + "context" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/dns/transport/fakeip" + "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/anytls" + "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/direct" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/mixed" + "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-box/protocol/redirect" + "github.com/sagernet/sing-box/protocol/shadowsocks" + "github.com/sagernet/sing-box/protocol/shadowtls" + "github.com/sagernet/sing-box/protocol/socks" + "github.com/sagernet/sing-box/protocol/ssh" + "github.com/sagernet/sing-box/protocol/tor" + "github.com/sagernet/sing-box/protocol/trojan" + "github.com/sagernet/sing-box/protocol/tun" + "github.com/sagernet/sing-box/protocol/vless" + "github.com/sagernet/sing-box/protocol/vmess" + originca "github.com/sagernet/sing-box/service/origin_ca" + "github.com/sagernet/sing-box/service/resolved" + "github.com/sagernet/sing-box/service/ssmapi" + E "github.com/sagernet/sing/common/exceptions" +) + +func Context(ctx context.Context) context.Context { + return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry()) +} + +func InboundRegistry() *inbound.Registry { + registry := inbound.NewRegistry() + + tun.RegisterInbound(registry) + redirect.RegisterRedirect(registry) + redirect.RegisterTProxy(registry) + direct.RegisterInbound(registry) + + socks.RegisterInbound(registry) + http.RegisterInbound(registry) + mixed.RegisterInbound(registry) + + shadowsocks.RegisterInbound(registry) + vmess.RegisterInbound(registry) + trojan.RegisterInbound(registry) + naive.RegisterInbound(registry) + shadowtls.RegisterInbound(registry) + vless.RegisterInbound(registry) + anytls.RegisterInbound(registry) + + registerQUICInbounds(registry) + registerCloudflaredInbound(registry) + registerStubForRemovedInbounds(registry) + + return registry +} + +func OutboundRegistry() *outbound.Registry { + registry := outbound.NewRegistry() + + direct.RegisterOutbound(registry) + + block.RegisterOutbound(registry) + + group.RegisterSelector(registry) + group.RegisterURLTest(registry) + + socks.RegisterOutbound(registry) + http.RegisterOutbound(registry) + shadowsocks.RegisterOutbound(registry) + vmess.RegisterOutbound(registry) + trojan.RegisterOutbound(registry) + registerNaiveOutbound(registry) + tor.RegisterOutbound(registry) + ssh.RegisterOutbound(registry) + shadowtls.RegisterOutbound(registry) + vless.RegisterOutbound(registry) + anytls.RegisterOutbound(registry) + + registerQUICOutbounds(registry) + registerStubForRemovedOutbounds(registry) + + return registry +} + +func EndpointRegistry() *endpoint.Registry { + registry := endpoint.NewRegistry() + + registerWireGuardEndpoint(registry) + registerTailscaleEndpoint(registry) + + return registry +} + +func DNSTransportRegistry() *dns.TransportRegistry { + registry := dns.NewTransportRegistry() + + transport.RegisterTCP(registry) + transport.RegisterUDP(registry) + transport.RegisterTLS(registry) + transport.RegisterHTTPS(registry) + hosts.RegisterTransport(registry) + local.RegisterTransport(registry) + fakeip.RegisterTransport(registry) + resolved.RegisterTransport(registry) + + registerQUICTransports(registry) + registerDHCPTransport(registry) + registerTailscaleTransport(registry) + + return registry +} + +func ServiceRegistry() *service.Registry { + registry := service.NewRegistry() + + resolved.RegisterService(registry) + ssmapi.RegisterService(registry) + + registerDERPService(registry) + registerCCMService(registry) + registerOCMService(registry) + registerOOMKillerService(registry) + + return registry +} + +func CertificateProviderRegistry() *certificate.Registry { + registry := certificate.NewRegistry() + + registerACMECertificateProvider(registry) + registerTailscaleCertificateProvider(registry) + originca.RegisterCertificateProvider(registry) + + return registry +} + +func registerStubForRemovedInbounds(registry *inbound.Registry) { + inbound.Register[option.ShadowsocksInboundOptions](registry, C.TypeShadowsocksR, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (adapter.Inbound, error) { + return nil, E.New("ShadowsocksR is deprecated and removed in sing-box 1.6.0") + }) +} + +func registerStubForRemovedOutbounds(registry *outbound.Registry) { + outbound.Register[option.ShadowsocksROutboundOptions](registry, C.TypeShadowsocksR, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksROutboundOptions) (adapter.Outbound, error) { + return nil, E.New("ShadowsocksR is deprecated and removed in sing-box 1.6.0") + }) + outbound.Register[option.StubOptions](registry, C.TypeWireGuard, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.StubOptions) (adapter.Outbound, error) { + return nil, E.New("WireGuard outbound is deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, use WireGuard endpoint instead") + }) +} diff --git a/include/tailscale.go b/include/tailscale.go new file mode 100644 index 00000000..6f85aaac --- /dev/null +++ b/include/tailscale.go @@ -0,0 +1,28 @@ +//go:build with_tailscale + +package include + +import ( + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/protocol/tailscale" + "github.com/sagernet/sing-box/service/derp" +) + +func registerTailscaleEndpoint(registry *endpoint.Registry) { + tailscale.RegisterEndpoint(registry) +} + +func registerTailscaleTransport(registry *dns.TransportRegistry) { + tailscale.RegistryTransport(registry) +} + +func registerTailscaleCertificateProvider(registry *certificate.Registry) { + tailscale.RegisterCertificateProvider(registry) +} + +func registerDERPService(registry *service.Registry) { + derp.Register(registry) +} diff --git a/include/tailscale_stub.go b/include/tailscale_stub.go new file mode 100644 index 00000000..e6f97f1e --- /dev/null +++ b/include/tailscale_stub.go @@ -0,0 +1,41 @@ +//go:build !with_tailscale + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerTailscaleEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.TailscaleEndpointOptions](registry, C.TypeTailscale, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailscaleEndpointOptions) (adapter.Endpoint, error) { + return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) + }) +} + +func registerTailscaleTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.TailscaleDNSServerOptions](registry, C.DNSTypeTailscale, func(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) + }) +} + +func registerTailscaleCertificateProvider(registry *certificate.Registry) { + certificate.Register[option.TailscaleCertificateProviderOptions](registry, C.TypeTailscale, func(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleCertificateProviderOptions) (adapter.CertificateProviderService, error) { + return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) + }) +} + +func registerDERPService(registry *service.Registry) { + service.Register[option.DERPServiceOptions](registry, C.TypeDERP, func(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPServiceOptions) (adapter.Service, error) { + return nil, E.New(`DERP is not included in this build, rebuild with -tags with_tailscale`) + }) +} diff --git a/include/tz_android.go b/include/tz_android.go new file mode 100644 index 00000000..7be1c2da --- /dev/null +++ b/include/tz_android.go @@ -0,0 +1,21 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// kanged from https://github.com/golang/mobile/blob/c713f31d574bb632a93f169b2cc99c9e753fef0e/app/android.go#L89 + +package include + +// #include +import "C" +import "time" + +func init() { + var currentT C.time_t + var currentTM C.struct_tm + C.time(¤tT) + C.localtime_r(¤tT, ¤tTM) + tzOffset := int(currentTM.tm_gmtoff) + tz := C.GoString(currentTM.tm_zone) + time.Local = time.FixedZone(tz, tzOffset) +} diff --git a/include/tz_ios.go b/include/tz_ios.go new file mode 100644 index 00000000..fc30479c --- /dev/null +++ b/include/tz_ios.go @@ -0,0 +1,30 @@ +package include + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation +#import +const char* getSystemTimeZone() { + NSTimeZone *timeZone = [NSTimeZone systemTimeZone]; + NSString *timeZoneName = [timeZone description]; + return [timeZoneName UTF8String]; +} +*/ +import "C" + +import ( + "strings" + "time" +) + +func init() { + tzDescription := C.GoString(C.getSystemTimeZone()) + if len(tzDescription) == 0 { + return + } + location, err := time.LoadLocation(strings.Split(tzDescription, " ")[0]) + if err != nil { + return + } + time.Local = location +} diff --git a/include/v2rayapi.go b/include/v2rayapi.go new file mode 100644 index 00000000..acd3e9a8 --- /dev/null +++ b/include/v2rayapi.go @@ -0,0 +1,5 @@ +//go:build with_v2ray_api + +package include + +import _ "github.com/sagernet/sing-box/experimental/v2rayapi" diff --git a/include/v2rayapi_stub.go b/include/v2rayapi_stub.go new file mode 100644 index 00000000..7f1f6b9e --- /dev/null +++ b/include/v2rayapi_stub.go @@ -0,0 +1,17 @@ +//go:build !with_v2ray_api + +package include + +import ( + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func init() { + experimental.RegisterV2RayServerConstructor(func(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) { + return nil, E.New(`v2ray api is not included in this build, rebuild with -tags with_v2ray_api`) + }) +} diff --git a/include/wireguard.go b/include/wireguard.go new file mode 100644 index 00000000..fa7cfe6f --- /dev/null +++ b/include/wireguard.go @@ -0,0 +1,12 @@ +//go:build with_wireguard + +package include + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/protocol/wireguard" +) + +func registerWireGuardEndpoint(registry *endpoint.Registry) { + wireguard.RegisterEndpoint(registry) +} diff --git a/include/wireguard_stub.go b/include/wireguard_stub.go new file mode 100644 index 00000000..e03a9d9c --- /dev/null +++ b/include/wireguard_stub.go @@ -0,0 +1,20 @@ +//go:build !with_wireguard + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerWireGuardEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.WireGuardEndpointOptions](registry, C.TypeWireGuard, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WireGuardEndpointOptions) (adapter.Endpoint, error) { + return nil, E.New(`WireGuard is not included in this build, rebuild with -tags with_wireguard`) + }) +} diff --git a/log/export.go b/log/export.go new file mode 100644 index 00000000..60a0abbb --- /dev/null +++ b/log/export.go @@ -0,0 +1,84 @@ +package log + +import ( + "context" + "os" + "time" +) + +var std ContextLogger + +func init() { + std = NewDefaultFactory( + context.Background(), + Formatter{BaseTime: time.Now()}, + os.Stderr, + "", + nil, + false, + ).Logger() +} + +func StdLogger() ContextLogger { + return std +} + +func SetStdLogger(logger ContextLogger) { + std = logger +} + +func Trace(args ...any) { + std.Trace(args...) +} + +func Debug(args ...any) { + std.Debug(args...) +} + +func Info(args ...any) { + std.Info(args...) +} + +func Warn(args ...any) { + std.Warn(args...) +} + +func Error(args ...any) { + std.Error(args...) +} + +func Fatal(args ...any) { + std.Fatal(args...) +} + +func Panic(args ...any) { + std.Panic(args...) +} + +func TraceContext(ctx context.Context, args ...any) { + std.TraceContext(ctx, args...) +} + +func DebugContext(ctx context.Context, args ...any) { + std.DebugContext(ctx, args...) +} + +func InfoContext(ctx context.Context, args ...any) { + std.InfoContext(ctx, args...) +} + +func WarnContext(ctx context.Context, args ...any) { + std.WarnContext(ctx, args...) +} + +func ErrorContext(ctx context.Context, args ...any) { + std.ErrorContext(ctx, args...) +} + +func FatalContext(ctx context.Context, args ...any) { + std.FatalContext(ctx, args...) +} + +func PanicContext(ctx context.Context, args ...any) { + std.PanicContext(ctx, args...) +} diff --git a/log/factory.go b/log/factory.go new file mode 100644 index 00000000..54a88228 --- /dev/null +++ b/log/factory.go @@ -0,0 +1,30 @@ +package log + +import ( + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/observable" +) + +type ( + Logger logger.Logger + ContextLogger logger.ContextLogger +) + +type Factory interface { + Start() error + Close() error + Level() Level + SetLevel(level Level) + Logger() ContextLogger + NewLogger(tag string) ContextLogger +} + +type ObservableFactory interface { + Factory + observable.Observable[Entry] +} + +type Entry struct { + Level Level + Message string +} diff --git a/log/format.go b/log/format.go new file mode 100644 index 00000000..6f4347b1 --- /dev/null +++ b/log/format.go @@ -0,0 +1,174 @@ +package log + +import ( + "context" + "strconv" + "strings" + "time" + + F "github.com/sagernet/sing/common/format" + + "github.com/logrusorgru/aurora" +) + +type Formatter struct { + BaseTime time.Time + DisableColors bool + DisableTimestamp bool + FullTimestamp bool + TimestampFormat string + DisableLineBreak bool +} + +func (f Formatter) Format(ctx context.Context, level Level, tag string, message string, timestamp time.Time) string { + levelString := strings.ToUpper(FormatLevel(level)) + if !f.DisableColors { + switch level { + case LevelDebug, LevelTrace: + levelString = aurora.White(levelString).String() + case LevelInfo: + levelString = aurora.Cyan(levelString).String() + case LevelWarn: + levelString = aurora.Yellow(levelString).String() + case LevelError, LevelFatal, LevelPanic: + levelString = aurora.Red(levelString).String() + } + } + if tag != "" { + message = tag + ": " + message + } + var id ID + var hasId bool + if ctx != nil { + id, hasId = IDFromContext(ctx) + } + if hasId { + activeDuration := FormatDuration(time.Since(id.CreatedAt)) + if !f.DisableColors { + var color aurora.Color + color = aurora.Color(uint8(id.ID)) + color %= 215 + row := uint(color / 36) + column := uint(color % 36) + + var r, g, b float32 + r = float32(row * 51) + g = float32(column / 6 * 51) + b = float32((column % 6) * 51) + luma := 0.2126*r + 0.7152*g + 0.0722*b + if luma < 60 { + row = 5 - row + column = 35 - column + color = aurora.Color(row*36 + column) + } + color += 16 + color = color << 16 + color |= 1 << 14 + message = F.ToString("[", aurora.Colorize(id.ID, color).String(), " ", activeDuration, "] ", message) + } else { + message = F.ToString("[", id.ID, " ", activeDuration, "] ", message) + } + } + switch { + case f.DisableTimestamp: + message = levelString + " " + message + case f.FullTimestamp: + message = timestamp.Format(f.TimestampFormat) + " " + levelString + " " + message + default: + message = levelString + "[" + xd(int(timestamp.Sub(f.BaseTime)/time.Second), 4) + "] " + message + } + if f.DisableLineBreak { + if message[len(message)-1] == '\n' { + message = message[:len(message)-1] + } + } else { + if message[len(message)-1] != '\n' { + message += "\n" + } + } + return message +} + +func (f Formatter) FormatWithSimple(ctx context.Context, level Level, tag string, message string, timestamp time.Time) (string, string) { + levelString := strings.ToUpper(FormatLevel(level)) + if !f.DisableColors { + switch level { + case LevelDebug, LevelTrace: + levelString = aurora.White(levelString).String() + case LevelInfo: + levelString = aurora.Cyan(levelString).String() + case LevelWarn: + levelString = aurora.Yellow(levelString).String() + case LevelError, LevelFatal, LevelPanic: + levelString = aurora.Red(levelString).String() + } + } + if tag != "" { + message = tag + ": " + message + } + messageSimple := message + var id ID + var hasId bool + if ctx != nil { + id, hasId = IDFromContext(ctx) + } + if hasId { + activeDuration := FormatDuration(time.Since(id.CreatedAt)) + if !f.DisableColors { + var color aurora.Color + color = aurora.Color(uint8(id.ID)) + color %= 215 + row := uint(color / 36) + column := uint(color % 36) + + var r, g, b float32 + r = float32(row * 51) + g = float32(column / 6 * 51) + b = float32((column % 6) * 51) + luma := 0.2126*r + 0.7152*g + 0.0722*b + if luma < 60 { + row = 5 - row + column = 35 - column + color = aurora.Color(row*36 + column) + } + color += 16 + color = color << 16 + color |= 1 << 14 + message = F.ToString("[", aurora.Colorize(id.ID, color).String(), " ", activeDuration, "] ", message) + } else { + message = F.ToString("[", id.ID, " ", activeDuration, "] ", message) + } + messageSimple = F.ToString("[", id.ID, " ", activeDuration, "] ", messageSimple) + + } + switch { + case f.DisableTimestamp: + message = levelString + " " + message + case f.FullTimestamp: + message = timestamp.Format(f.TimestampFormat) + " " + levelString + " " + message + default: + message = levelString + "[" + xd(int(timestamp.Sub(f.BaseTime)/time.Second), 4) + "] " + message + } + if message[len(message)-1] != '\n' { + message += "\n" + } + return message, messageSimple +} + +func xd(value int, x int) string { + message := strconv.Itoa(value) + for len(message) < x { + message = "0" + message + } + return message +} + +func FormatDuration(duration time.Duration) string { + if duration < time.Second { + return F.ToString(duration.Milliseconds(), "ms") + } else if duration < time.Minute { + return F.ToString(int64(duration.Seconds()), ".", int64(duration.Seconds()*100)%100, "s") + } else { + return F.ToString(int64(duration.Minutes()), "m", int64(duration.Seconds())%60, "s") + } +} diff --git a/log/id.go b/log/id.go new file mode 100644 index 00000000..7cac29d2 --- /dev/null +++ b/log/id.go @@ -0,0 +1,36 @@ +package log + +import ( + "context" + "math/rand" + "time" + + "github.com/sagernet/sing/common/random" +) + +func init() { + random.InitializeSeed() +} + +type idKey struct{} + +type ID struct { + ID uint32 + CreatedAt time.Time +} + +func ContextWithNewID(ctx context.Context) context.Context { + return ContextWithID(ctx, ID{ + ID: rand.Uint32(), + CreatedAt: time.Now(), + }) +} + +func ContextWithID(ctx context.Context, id ID) context.Context { + return context.WithValue(ctx, (*idKey)(nil), id) +} + +func IDFromContext(ctx context.Context) (ID, bool) { + id, loaded := ctx.Value((*idKey)(nil)).(ID) + return id, loaded +} diff --git a/log/level.go b/log/level.go new file mode 100644 index 00000000..b216fa34 --- /dev/null +++ b/log/level.go @@ -0,0 +1,59 @@ +package log + +import ( + E "github.com/sagernet/sing/common/exceptions" +) + +type Level = uint8 + +const ( + LevelPanic Level = iota + LevelFatal + LevelError + LevelWarn + LevelInfo + LevelDebug + LevelTrace +) + +func FormatLevel(level Level) string { + switch level { + case LevelTrace: + return "trace" + case LevelDebug: + return "debug" + case LevelInfo: + return "info" + case LevelWarn: + return "warn" + case LevelError: + return "error" + case LevelFatal: + return "fatal" + case LevelPanic: + return "panic" + default: + return "unknown" + } +} + +func ParseLevel(level string) (Level, error) { + switch level { + case "trace": + return LevelTrace, nil + case "debug": + return LevelDebug, nil + case "info": + return LevelInfo, nil + case "warn", "warning": + return LevelWarn, nil + case "error": + return LevelError, nil + case "fatal": + return LevelFatal, nil + case "panic": + return LevelPanic, nil + default: + return LevelTrace, E.New("unknown log level: ", level) + } +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 00000000..3a1c6537 --- /dev/null +++ b/log/log.go @@ -0,0 +1,71 @@ +package log + +import ( + "context" + "io" + "os" + "time" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +type Options struct { + Context context.Context + Options option.LogOptions + Observable bool + DefaultWriter io.Writer + BaseTime time.Time + PlatformWriter PlatformWriter +} + +func New(options Options) (Factory, error) { + logOptions := options.Options + + if logOptions.Disabled { + return NewNOPFactory(), nil + } + + var logWriter io.Writer + var logFilePath string + + switch logOptions.Output { + case "": + logWriter = options.DefaultWriter + if logWriter == nil { + logWriter = os.Stderr + } + case "stderr": + logWriter = os.Stderr + case "stdout": + logWriter = os.Stdout + default: + logWriter = io.Discard + logFilePath = logOptions.Output + } + logFormatter := Formatter{ + BaseTime: options.BaseTime, + DisableColors: logOptions.DisableColor || logFilePath != "", + DisableTimestamp: !logOptions.Timestamp && logFilePath != "", + FullTimestamp: logOptions.Timestamp, + TimestampFormat: "-0700 2006-01-02 15:04:05", + } + factory := NewDefaultFactory( + options.Context, + logFormatter, + logWriter, + logFilePath, + options.PlatformWriter, + options.Observable, + ) + if logOptions.Level != "" { + logLevel, err := ParseLevel(logOptions.Level) + if err != nil { + return nil, E.Cause(err, "parse log level") + } + factory.SetLevel(logLevel) + } else { + factory.SetLevel(LevelTrace) + } + return factory, nil +} diff --git a/log/nop.go b/log/nop.go new file mode 100644 index 00000000..6369e99b --- /dev/null +++ b/log/nop.go @@ -0,0 +1,88 @@ +package log + +import ( + "context" + "os" + + "github.com/sagernet/sing/common/observable" +) + +var _ ObservableFactory = (*nopFactory)(nil) + +type nopFactory struct{} + +func NewNOPFactory() ObservableFactory { + return (*nopFactory)(nil) +} + +func (f *nopFactory) Start() error { + return nil +} + +func (f *nopFactory) Close() error { + return nil +} + +func (f *nopFactory) Level() Level { + return LevelTrace +} + +func (f *nopFactory) SetLevel(level Level) { +} + +func (f *nopFactory) Logger() ContextLogger { + return f +} + +func (f *nopFactory) NewLogger(tag string) ContextLogger { + return f +} + +func (f *nopFactory) Trace(args ...any) { +} + +func (f *nopFactory) Debug(args ...any) { +} + +func (f *nopFactory) Info(args ...any) { +} + +func (f *nopFactory) Warn(args ...any) { +} + +func (f *nopFactory) Error(args ...any) { +} + +func (f *nopFactory) Fatal(args ...any) { +} + +func (f *nopFactory) Panic(args ...any) { +} + +func (f *nopFactory) TraceContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) DebugContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) InfoContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) WarnContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) ErrorContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) FatalContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) PanicContext(ctx context.Context, args ...any) { +} + +func (f *nopFactory) Subscribe() (subscription observable.Subscription[Entry], done <-chan struct{}, err error) { + return nil, nil, os.ErrInvalid +} + +func (f *nopFactory) UnSubscribe(subscription observable.Subscription[Entry]) { +} diff --git a/log/observable.go b/log/observable.go new file mode 100644 index 00000000..768942bd --- /dev/null +++ b/log/observable.go @@ -0,0 +1,199 @@ +package log + +import ( + "context" + "io" + "os" + "time" + + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/service/filemanager" +) + +var _ Factory = (*defaultFactory)(nil) + +type defaultFactory struct { + ctx context.Context + formatter Formatter + platformFormatter Formatter + writer io.Writer + file *os.File + filePath string + platformWriter PlatformWriter + needObservable bool + level Level + subscriber *observable.Subscriber[Entry] + observer *observable.Observer[Entry] +} + +func NewDefaultFactory( + ctx context.Context, + formatter Formatter, + writer io.Writer, + filePath string, + platformWriter PlatformWriter, + needObservable bool, +) ObservableFactory { + factory := &defaultFactory{ + ctx: ctx, + formatter: formatter, + platformFormatter: Formatter{ + BaseTime: formatter.BaseTime, + DisableLineBreak: true, + }, + writer: writer, + filePath: filePath, + platformWriter: platformWriter, + needObservable: needObservable, + level: LevelTrace, + subscriber: observable.NewSubscriber[Entry](128), + } + /*if platformWriter != nil { + factory.platformFormatter.DisableColors = platformWriter.DisableColors() + }*/ + if needObservable { + factory.observer = observable.NewObserver[Entry](factory.subscriber, 64) + } + return factory +} + +func (f *defaultFactory) Start() error { + if f.filePath != "" { + logFile, err := filemanager.OpenFile(f.ctx, f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + f.writer = logFile + f.file = logFile + } + return nil +} + +func (f *defaultFactory) Close() error { + return common.Close( + common.PtrOrNil(f.file), + f.subscriber, + ) +} + +func (f *defaultFactory) Level() Level { + return f.level +} + +func (f *defaultFactory) SetLevel(level Level) { + f.level = level +} + +func (f *defaultFactory) Logger() ContextLogger { + return f.NewLogger("") +} + +func (f *defaultFactory) NewLogger(tag string) ContextLogger { + return &observableLogger{f, tag} +} + +func (f *defaultFactory) Subscribe() (subscription observable.Subscription[Entry], done <-chan struct{}, err error) { + return f.observer.Subscribe() +} + +func (f *defaultFactory) UnSubscribe(sub observable.Subscription[Entry]) { + f.observer.UnSubscribe(sub) +} + +var _ ContextLogger = (*observableLogger)(nil) + +type observableLogger struct { + *defaultFactory + tag string +} + +func (l *observableLogger) Log(ctx context.Context, level Level, args []any) { + level = OverrideLevelFromContext(level, ctx) + if level > l.level && l.platformWriter == nil { + return + } + nowTime := time.Now() + if level <= l.level { + if l.needObservable { + message, messageSimple := l.formatter.FormatWithSimple(ctx, level, l.tag, F.ToString(args...), nowTime) + if level == LevelPanic { + panic(message) + } + l.writer.Write([]byte(message)) + if level == LevelFatal { + os.Exit(1) + } + l.subscriber.Emit(Entry{level, messageSimple}) + } else { + message := l.formatter.Format(ctx, level, l.tag, F.ToString(args...), nowTime) + if level == LevelPanic { + panic(message) + } + l.writer.Write([]byte(message)) + if level == LevelFatal { + os.Exit(1) + } + } + } + if l.platformWriter != nil { + l.platformWriter.WriteMessage(level, l.platformFormatter.Format(ctx, level, l.tag, F.ToString(args...), nowTime)) + } +} + +func (l *observableLogger) Trace(args ...any) { + l.TraceContext(context.Background(), args...) +} + +func (l *observableLogger) Debug(args ...any) { + l.DebugContext(context.Background(), args...) +} + +func (l *observableLogger) Info(args ...any) { + l.InfoContext(context.Background(), args...) +} + +func (l *observableLogger) Warn(args ...any) { + l.WarnContext(context.Background(), args...) +} + +func (l *observableLogger) Error(args ...any) { + l.ErrorContext(context.Background(), args...) +} + +func (l *observableLogger) Fatal(args ...any) { + l.FatalContext(context.Background(), args...) +} + +func (l *observableLogger) Panic(args ...any) { + l.PanicContext(context.Background(), args...) +} + +func (l *observableLogger) TraceContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelTrace, args) +} + +func (l *observableLogger) DebugContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelDebug, args) +} + +func (l *observableLogger) InfoContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelInfo, args) +} + +func (l *observableLogger) WarnContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelWarn, args) +} + +func (l *observableLogger) ErrorContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelError, args) +} + +func (l *observableLogger) FatalContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelFatal, args) +} + +func (l *observableLogger) PanicContext(ctx context.Context, args ...any) { + l.Log(ctx, LevelPanic, args) +} diff --git a/log/override.go b/log/override.go new file mode 100644 index 00000000..42e26e60 --- /dev/null +++ b/log/override.go @@ -0,0 +1,19 @@ +package log + +import ( + "context" +) + +type overrideLevelKey struct{} + +func ContextWithOverrideLevel(ctx context.Context, level Level) context.Context { + return context.WithValue(ctx, (*overrideLevelKey)(nil), level) +} + +func OverrideLevelFromContext(origin Level, ctx context.Context) Level { + level, loaded := ctx.Value((*overrideLevelKey)(nil)).(Level) + if !loaded || origin > level { + return origin + } + return level +} diff --git a/log/platform.go b/log/platform.go new file mode 100644 index 00000000..a8881d4c --- /dev/null +++ b/log/platform.go @@ -0,0 +1,5 @@ +package log + +type PlatformWriter interface { + WriteMessage(level Level, message string) +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..5387be9d --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,295 @@ +site_name: sing-box +site_url: https://sing-box.sagernet.org/ +site_author: nekohasekai +repo_url: https://github.com/SagerNet/sing-box +repo_name: SagerNet/sing-box +copyright: Copyright © 2022 nekohasekai +site_description: The universal proxy platform. +remote_branch: docs +edit_uri: "" +theme: + name: material + logo: assets/icon.svg + favicon: assets/icon.svg + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/link + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: white + toggle: + icon: material/toggle-switch + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + toggle: + icon: material/toggle-switch-off + name: Switch to system preference + features: + # - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.indexes + - navigation.expand + - navigation.sections + - header.autohide + - content.code.copy + - content.code.select + - content.code.annotate + icon: + admonition: + question: material/new-box +nav: + - Home: + - index.md + - Change Log: changelog.md + - Migration: migration.md + - Deprecated: deprecated.md + - Support: support.md + - Sponsors: sponsors.md + - Installation: + - Package Manager: installation/package-manager.md + - Docker: installation/docker.md + - Build from source: installation/build-from-source.md + - Graphical Clients: + - clients/index.md + - Android: + - clients/android/index.md + - Features: clients/android/features.md + - Apple platforms: + - clients/apple/index.md + - Features: clients/apple/features.md + - General: clients/general.md + - Privacy policy: clients/privacy.md + - Manual: + - Proxy: + - Server: manual/proxy/server.md + - Client: manual/proxy/client.md + # - TUN: manual/proxy/tun.md + - Proxy Protocol: + - Shadowsocks: manual/proxy-protocol/shadowsocks.md + - Trojan: manual/proxy-protocol/trojan.md + - Hysteria 2: manual/proxy-protocol/hysteria2.md + - Misc: + - TunnelVision: manual/misc/tunnelvision.md + - Configuration: + - configuration/index.md + - Log: + - configuration/log/index.md + - DNS: + - configuration/dns/index.md + - DNS Server: + - configuration/dns/server/index.md + - Legacy: configuration/dns/server/legacy.md + - Local: configuration/dns/server/local.md + - Hosts: configuration/dns/server/hosts.md + - TCP: configuration/dns/server/tcp.md + - UDP: configuration/dns/server/udp.md + - TLS: configuration/dns/server/tls.md + - QUIC: configuration/dns/server/quic.md + - HTTPS: configuration/dns/server/https.md + - HTTP3: configuration/dns/server/http3.md + - DHCP: configuration/dns/server/dhcp.md + - FakeIP: configuration/dns/server/fakeip.md + - Tailscale: configuration/dns/server/tailscale.md + - Resolved: configuration/dns/server/resolved.md + - DNS Rule: configuration/dns/rule.md + - DNS Rule Action: configuration/dns/rule_action.md + - FakeIP: configuration/dns/fakeip.md + - NTP: configuration/ntp/index.md + - Certificate: configuration/certificate/index.md + - Route: + - configuration/route/index.md + - GeoIP: configuration/route/geoip.md + - Geosite: configuration/route/geosite.md + - Route Rule: configuration/route/rule.md + - Rule Action: configuration/route/rule_action.md + - Protocol Sniff: configuration/route/sniff.md + - Rule Set: + - configuration/rule-set/index.md + - Source Format: configuration/rule-set/source-format.md + - Headless Rule: configuration/rule-set/headless-rule.md + - AdGuard DNS Filer: configuration/rule-set/adguard.md + - Experimental: + - configuration/experimental/index.md + - Cache File: configuration/experimental/cache-file.md + - Clash API: configuration/experimental/clash-api.md + - V2Ray API: configuration/experimental/v2ray-api.md + - Shared: + - Listen Fields: configuration/shared/listen.md + - Dial Fields: configuration/shared/dial.md + - TLS: configuration/shared/tls.md + - Certificate Provider: + - configuration/shared/certificate-provider/index.md + - ACME: configuration/shared/certificate-provider/acme.md + - Tailscale: configuration/shared/certificate-provider/tailscale.md + - Cloudflare Origin CA: configuration/shared/certificate-provider/cloudflare-origin-ca.md + - DNS01 Challenge Fields: configuration/shared/dns01_challenge.md + - Pre-match: configuration/shared/pre-match.md + - Multiplex: configuration/shared/multiplex.md + - V2Ray Transport: configuration/shared/v2ray-transport.md + - UDP over TCP: configuration/shared/udp-over-tcp.md + - TCP Brutal: configuration/shared/tcp-brutal.md + - Wi-Fi State: configuration/shared/wifi-state.md + - Neighbor Resolution: configuration/shared/neighbor.md + - Endpoint: + - configuration/endpoint/index.md + - WireGuard: configuration/endpoint/wireguard.md + - Tailscale: configuration/endpoint/tailscale.md + - Inbound: + - configuration/inbound/index.md + - Direct: configuration/inbound/direct.md + - Mixed: configuration/inbound/mixed.md + - SOCKS: configuration/inbound/socks.md + - HTTP: configuration/inbound/http.md + - Shadowsocks: configuration/inbound/shadowsocks.md + - VMess: configuration/inbound/vmess.md + - Trojan: configuration/inbound/trojan.md + - Naive: configuration/inbound/naive.md + - Hysteria: configuration/inbound/hysteria.md + - ShadowTLS: configuration/inbound/shadowtls.md + - VLESS: configuration/inbound/vless.md + - TUIC: configuration/inbound/tuic.md + - Hysteria2: configuration/inbound/hysteria2.md + - AnyTLS: configuration/inbound/anytls.md + - Tun: configuration/inbound/tun.md + - Redirect: configuration/inbound/redirect.md + - TProxy: configuration/inbound/tproxy.md + - Cloudflared: configuration/inbound/cloudflared.md + - Outbound: + - configuration/outbound/index.md + - Direct: configuration/outbound/direct.md + - Block: configuration/outbound/block.md + - SOCKS: configuration/outbound/socks.md + - HTTP: configuration/outbound/http.md + - Shadowsocks: configuration/outbound/shadowsocks.md + - VMess: configuration/outbound/vmess.md + - Trojan: configuration/outbound/trojan.md + - Naive: configuration/outbound/naive.md + - WireGuard: configuration/outbound/wireguard.md + - Hysteria: configuration/outbound/hysteria.md + - ShadowTLS: configuration/outbound/shadowtls.md + - VLESS: configuration/outbound/vless.md + - TUIC: configuration/outbound/tuic.md + - Hysteria2: configuration/outbound/hysteria2.md + - AnyTLS: configuration/outbound/anytls.md + - Tor: configuration/outbound/tor.md + - SSH: configuration/outbound/ssh.md + - DNS: configuration/outbound/dns.md + - Selector: configuration/outbound/selector.md + - URLTest: configuration/outbound/urltest.md + - Service: + - configuration/service/index.md + - DERP: configuration/service/derp.md + - Resolved: configuration/service/resolved.md + - SSM API: configuration/service/ssm-api.md + - CCM: configuration/service/ccm.md + - OCM: configuration/service/ocm.md +markdown_extensions: + - toc: + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.details + - pymdownx.critic + - pymdownx.caret + - pymdownx.keys + - pymdownx.mark + - pymdownx.tilde + - pymdownx.magiclink + - admonition + - attr_list + - md_in_html + - footnotes + - def_list + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/SagerNet/sing-box + generator: false +plugins: + - search + - i18n: + docs_structure: suffix + fallback_to_default: true + languages: + - build: true + default: true + locale: en + name: English + - build: true + default: false + locale: zh + name: 简体中文 + nav_translations: + Home: 开始 + Change Log: 更新日志 + Migration: 迁移指南 + Deprecated: 废弃功能列表 + Support: 支持 + + Installation: 安装 + Package Manager: 包管理器 + Build from source: 从源代码构建 + + Graphical Clients: 图形界面客户端 + Features: 特性 + Apple platforms: Apple 平台 + General: 通用 + Privacy policy: 隐私政策 + + Configuration: 配置 + Log: 日志 + DNS Server: DNS 服务器 + DNS Rule: DNS 规则 + DNS Rule Action: DNS 规则动作 + + Route: 路由 + Route Rule: 路由规则 + Rule Action: 规则动作 + Protocol Sniff: 协议探测 + + Rule Set: 规则集 + Source Format: 源文件格式 + Headless Rule: 无头规则 + + Experimental: 实验性 + Cache File: 缓存文件 + + Shared: 通用 + Listen Fields: 监听字段 + Dial Fields: 拨号字段 + Certificate Provider Fields: 证书提供者字段 + DNS01 Challenge Fields: DNS01 验证字段 + Multiplex: 多路复用 + V2Ray Transport: V2Ray 传输层 + Wi-Fi State: Wi-Fi 状态 + + Endpoint: 端点 + Inbound: 入站 + Outbound: 出站 + Certificate Provider: 证书提供者 + + Manual: 手册 + reconfigure_material: true + reconfigure_search: true diff --git a/option/acme.go b/option/acme.go new file mode 100644 index 00000000..ea9349b7 --- /dev/null +++ b/option/acme.go @@ -0,0 +1,106 @@ +package option + +import ( + "strings" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type ACMECertificateProviderOptions struct { + Domain badoption.Listable[string] `json:"domain,omitempty"` + DataDirectory string `json:"data_directory,omitempty"` + DefaultServerName string `json:"default_server_name,omitempty"` + Email string `json:"email,omitempty"` + Provider string `json:"provider,omitempty"` + AccountKey string `json:"account_key,omitempty"` + DisableHTTPChallenge bool `json:"disable_http_challenge,omitempty"` + DisableTLSALPNChallenge bool `json:"disable_tls_alpn_challenge,omitempty"` + AlternativeHTTPPort uint16 `json:"alternative_http_port,omitempty"` + AlternativeTLSPort uint16 `json:"alternative_tls_port,omitempty"` + ExternalAccount *ACMEExternalAccountOptions `json:"external_account,omitempty"` + DNS01Challenge *ACMEProviderDNS01ChallengeOptions `json:"dns01_challenge,omitempty"` + KeyType ACMEKeyType `json:"key_type,omitempty"` + Detour string `json:"detour,omitempty"` +} + +type _ACMEProviderDNS01ChallengeOptions struct { + TTL badoption.Duration `json:"ttl,omitempty"` + PropagationDelay badoption.Duration `json:"propagation_delay,omitempty"` + PropagationTimeout badoption.Duration `json:"propagation_timeout,omitempty"` + Resolvers badoption.Listable[string] `json:"resolvers,omitempty"` + OverrideDomain string `json:"override_domain,omitempty"` + Provider string `json:"provider,omitempty"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` +} + +type ACMEProviderDNS01ChallengeOptions _ACMEProviderDNS01ChallengeOptions + +func (o ACMEProviderDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { + var v any + switch o.Provider { + case C.DNSProviderAliDNS: + v = o.AliDNSOptions + case C.DNSProviderCloudflare: + v = o.CloudflareOptions + case C.DNSProviderACMEDNS: + v = o.ACMEDNSOptions + case "": + return nil, E.New("missing provider type") + default: + return nil, E.New("unknown provider type: ", o.Provider) + } + return badjson.MarshallObjects((_ACMEProviderDNS01ChallengeOptions)(o), v) +} + +func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_ACMEProviderDNS01ChallengeOptions)(o)) + if err != nil { + return err + } + var v any + switch o.Provider { + case C.DNSProviderAliDNS: + v = &o.AliDNSOptions + case C.DNSProviderCloudflare: + v = &o.CloudflareOptions + case C.DNSProviderACMEDNS: + v = &o.ACMEDNSOptions + case "": + return E.New("missing provider type") + default: + return E.New("unknown provider type: ", o.Provider) + } + return badjson.UnmarshallExcluded(bytes, (*_ACMEProviderDNS01ChallengeOptions)(o), v) +} + +type ACMEKeyType string + +const ( + ACMEKeyTypeED25519 = ACMEKeyType("ed25519") + ACMEKeyTypeP256 = ACMEKeyType("p256") + ACMEKeyTypeP384 = ACMEKeyType("p384") + ACMEKeyTypeRSA2048 = ACMEKeyType("rsa2048") + ACMEKeyTypeRSA4096 = ACMEKeyType("rsa4096") +) + +func (t *ACMEKeyType) UnmarshalJSON(data []byte) error { + var value string + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + value = strings.ToLower(value) + switch ACMEKeyType(value) { + case "", ACMEKeyTypeED25519, ACMEKeyTypeP256, ACMEKeyTypeP384, ACMEKeyTypeRSA2048, ACMEKeyTypeRSA4096: + *t = ACMEKeyType(value) + default: + return E.New("unknown ACME key type: ", value) + } + return nil +} diff --git a/option/anytls.go b/option/anytls.go new file mode 100644 index 00000000..0f785263 --- /dev/null +++ b/option/anytls.go @@ -0,0 +1,25 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type AnyTLSInboundOptions struct { + ListenOptions + InboundTLSOptionsContainer + Users []AnyTLSUser `json:"users,omitempty"` + PaddingScheme badoption.Listable[string] `json:"padding_scheme,omitempty"` +} + +type AnyTLSUser struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` +} + +type AnyTLSOutboundOptions struct { + DialerOptions + ServerOptions + OutboundTLSOptionsContainer + Password string `json:"password,omitempty"` + IdleSessionCheckInterval badoption.Duration `json:"idle_session_check_interval,omitempty"` + IdleSessionTimeout badoption.Duration `json:"idle_session_timeout,omitempty"` + MinIdleSession int `json:"min_idle_session,omitempty"` +} diff --git a/option/ccm.go b/option/ccm.go new file mode 100644 index 00000000..c916aaf2 --- /dev/null +++ b/option/ccm.go @@ -0,0 +1,20 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type CCMServiceOptions struct { + ListenOptions + InboundTLSOptionsContainer + CredentialPath string `json:"credential_path,omitempty"` + Users []CCMUser `json:"users,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + Detour string `json:"detour,omitempty"` + UsagesPath string `json:"usages_path,omitempty"` +} + +type CCMUser struct { + Name string `json:"name,omitempty"` + Token string `json:"token,omitempty"` +} diff --git a/option/certificate.go b/option/certificate.go new file mode 100644 index 00000000..ab524b99 --- /dev/null +++ b/option/certificate.go @@ -0,0 +1,36 @@ +package option + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +type _CertificateOptions struct { + Store string `json:"store,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath badoption.Listable[string] `json:"certificate_path,omitempty"` + CertificateDirectoryPath badoption.Listable[string] `json:"certificate_directory_path,omitempty"` +} + +type CertificateOptions _CertificateOptions + +func (o CertificateOptions) MarshalJSON() ([]byte, error) { + switch o.Store { + case C.CertificateStoreSystem: + o.Store = "" + } + return json.Marshal((*_CertificateOptions)(&o)) +} + +func (o *CertificateOptions) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*_CertificateOptions)(o)) + if err != nil { + return err + } + switch o.Store { + case C.CertificateStoreSystem, "": + o.Store = C.CertificateStoreSystem + } + return nil +} diff --git a/option/certificate_provider.go b/option/certificate_provider.go new file mode 100644 index 00000000..a24abdc5 --- /dev/null +++ b/option/certificate_provider.go @@ -0,0 +1,100 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/service" +) + +type CertificateProviderOptionsRegistry interface { + CreateOptions(providerType string) (any, bool) +} + +type _CertificateProvider struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type CertificateProvider _CertificateProvider + +func (h *CertificateProvider) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_CertificateProvider)(h), h.Options) +} + +func (h *CertificateProvider) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_CertificateProvider)(h)) + if err != nil { + return err + } + registry := service.FromContext[CertificateProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing certificate provider options registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown certificate provider type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_CertificateProvider)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} + +type CertificateProviderOptions struct { + Tag string `json:"-"` + Type string `json:"-"` + Options any `json:"-"` +} + +type _CertificateProviderInline struct { + Type string `json:"type"` +} + +func (o *CertificateProviderOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { + if o.Tag != "" { + return json.Marshal(o.Tag) + } + return badjson.MarshallObjectsContext(ctx, _CertificateProviderInline{Type: o.Type}, o.Options) +} + +func (o *CertificateProviderOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + if len(content) == 0 { + return E.New("empty certificate_provider value") + } + if content[0] == '"' { + return json.UnmarshalContext(ctx, content, &o.Tag) + } + var inline _CertificateProviderInline + err := json.UnmarshalContext(ctx, content, &inline) + if err != nil { + return err + } + o.Type = inline.Type + if o.Type == "" { + return E.New("missing certificate provider type") + } + registry := service.FromContext[CertificateProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing certificate provider options registry in context") + } + options, loaded := registry.CreateOptions(o.Type) + if !loaded { + return E.New("unknown certificate provider type: ", o.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, &inline, options) + if err != nil { + return err + } + o.Options = options + return nil +} + +func (o *CertificateProviderOptions) IsShared() bool { + return o.Tag != "" +} diff --git a/option/cloudflared.go b/option/cloudflared.go new file mode 100644 index 00000000..e94a20fe --- /dev/null +++ b/option/cloudflared.go @@ -0,0 +1,16 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type CloudflaredInboundOptions struct { + Token string `json:"token,omitempty"` + HighAvailabilityConnections int `json:"ha_connections,omitempty"` + Protocol string `json:"protocol,omitempty"` + PostQuantum bool `json:"post_quantum,omitempty"` + EdgeIPVersion int `json:"edge_ip_version,omitempty"` + DatagramVersion string `json:"datagram_version,omitempty"` + GracePeriod badoption.Duration `json:"grace_period,omitempty"` + Region string `json:"region,omitempty"` + ControlDialer DialerOptions `json:"control_dialer,omitempty"` + TunnelDialer DialerOptions `json:"tunnel_dialer,omitempty"` +} diff --git a/option/debug.go b/option/debug.go new file mode 100644 index 00000000..3dfef7b0 --- /dev/null +++ b/option/debug.go @@ -0,0 +1,14 @@ +package option + +import "github.com/sagernet/sing/common/byteformats" + +type DebugOptions struct { + Listen string `json:"listen,omitempty"` + GCPercent *int `json:"gc_percent,omitempty"` + MaxStack *int `json:"max_stack,omitempty"` + MaxThreads *int `json:"max_threads,omitempty"` + PanicOnFault *bool `json:"panic_on_fault,omitempty"` + TraceBack string `json:"trace_back,omitempty"` + MemoryLimit *byteformats.MemoryBytes `json:"memory_limit,omitempty"` + OOMKiller *bool `json:"oom_killer,omitempty"` +} diff --git a/option/direct.go b/option/direct.go new file mode 100644 index 00000000..a03f98d4 --- /dev/null +++ b/option/direct.go @@ -0,0 +1,39 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +type DirectInboundOptions struct { + ListenOptions + Network NetworkList `json:"network,omitempty"` + OverrideAddress string `json:"override_address,omitempty"` + OverridePort uint16 `json:"override_port,omitempty"` +} + +type _DirectOutboundOptions struct { + DialerOptions + // Deprecated: Use Route Action instead + OverrideAddress string `json:"override_address,omitempty"` + // Deprecated: Use Route Action instead + OverridePort uint16 `json:"override_port,omitempty"` + // Deprecated: removed + ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` +} + +type DirectOutboundOptions _DirectOutboundOptions + +func (d *DirectOutboundOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalDisallowUnknownFields(content, (*_DirectOutboundOptions)(d)) + if err != nil { + return err + } + //nolint:staticcheck + if d.OverrideAddress != "" || d.OverridePort != 0 { + return E.New("destination override fields in direct outbound are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, use route options instead") + } + return nil +} diff --git a/option/dns.go b/option/dns.go new file mode 100644 index 00000000..c09b3d5f --- /dev/null +++ b/option/dns.go @@ -0,0 +1,184 @@ +package option + +import ( + "context" + "net/netip" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/service" +) + +type RawDNSOptions struct { + Servers []DNSServerOptions `json:"servers,omitempty"` + Rules []DNSRule `json:"rules,omitempty"` + Final string `json:"final,omitempty"` + ReverseMapping bool `json:"reverse_mapping,omitempty"` + DNSClientOptions +} + +type DNSOptions struct { + RawDNSOptions +} + +const ( + legacyDNSFakeIPRemovedMessage = "legacy DNS fakeip options are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats" + legacyDNSServerRemovedMessage = "legacy DNS server formats are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats" +) + +type removedLegacyDNSOptions struct { + FakeIP json.RawMessage `json:"fakeip,omitempty"` +} + +func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + var legacyOptions removedLegacyDNSOptions + err := json.UnmarshalContext(ctx, content, &legacyOptions) + if err != nil { + return err + } + if len(legacyOptions.FakeIP) != 0 { + return E.New(legacyDNSFakeIPRemovedMessage) + } + return badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions) +} + +type DNSClientOptions struct { + Strategy DomainStrategy `json:"strategy,omitempty"` + DisableCache bool `json:"disable_cache,omitempty"` + DisableExpire bool `json:"disable_expire,omitempty"` + IndependentCache bool `json:"independent_cache,omitempty"` + CacheCapacity uint32 `json:"cache_capacity,omitempty"` + Optimistic *OptimisticDNSOptions `json:"optimistic,omitempty"` + ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` +} + +type _OptimisticDNSOptions struct { + Enabled bool `json:"enabled,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` +} + +type OptimisticDNSOptions _OptimisticDNSOptions + +func (o OptimisticDNSOptions) MarshalJSON() ([]byte, error) { + if o.Timeout == 0 { + return json.Marshal(o.Enabled) + } + return json.Marshal((_OptimisticDNSOptions)(o)) +} + +func (o *OptimisticDNSOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, &o.Enabled) + if err == nil { + return nil + } + return json.UnmarshalDisallowUnknownFields(bytes, (*_OptimisticDNSOptions)(o)) +} + +type DNSTransportOptionsRegistry interface { + CreateOptions(transportType string) (any, bool) +} +type _DNSServerOptions struct { + Type string `json:"type,omitempty"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type DNSServerOptions _DNSServerOptions + +func (o *DNSServerOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_DNSServerOptions)(o), o.Options) +} + +func (o *DNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_DNSServerOptions)(o)) + if err != nil { + return err + } + registry := service.FromContext[DNSTransportOptionsRegistry](ctx) + if registry == nil { + return E.New("missing DNS transport options registry in context") + } + var options any + switch o.Type { + case "", C.DNSTypeLegacy: + return E.New(legacyDNSServerRemovedMessage) + default: + var loaded bool + options, loaded = registry.CreateOptions(o.Type) + if !loaded { + return E.New("unknown transport type: ", o.Type) + } + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_DNSServerOptions)(o), options) + if err != nil { + return err + } + o.Options = options + return nil +} + +type DNSServerAddressOptions struct { + Server string `json:"server"` + ServerPort uint16 `json:"server_port,omitempty"` +} + +func (o DNSServerAddressOptions) Build() M.Socksaddr { + return M.ParseSocksaddrHostPort(o.Server, o.ServerPort) +} + +func (o DNSServerAddressOptions) ServerIsDomain() bool { + return o.Build().IsDomain() +} + +func (o *DNSServerAddressOptions) TakeServerOptions() ServerOptions { + return ServerOptions(*o) +} + +func (o *DNSServerAddressOptions) ReplaceServerOptions(options ServerOptions) { + *o = DNSServerAddressOptions(options) +} + +type HostsDNSServerOptions struct { + Path badoption.Listable[string] `json:"path,omitempty"` + Predefined *badjson.TypedMap[string, badoption.Listable[netip.Addr]] `json:"predefined,omitempty"` +} + +type RawLocalDNSServerOptions struct { + DialerOptions +} + +type LocalDNSServerOptions struct { + RawLocalDNSServerOptions + PreferGo bool `json:"prefer_go,omitempty"` +} + +type RemoteDNSServerOptions struct { + RawLocalDNSServerOptions + DNSServerAddressOptions +} + +type RemoteTLSDNSServerOptions struct { + RemoteDNSServerOptions + OutboundTLSOptionsContainer +} + +type RemoteHTTPSDNSServerOptions struct { + RemoteTLSDNSServerOptions + Path string `json:"path,omitempty"` + Method string `json:"method,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` +} + +type FakeIPDNSServerOptions struct { + Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` + Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` +} + +type DHCPDNSServerOptions struct { + LocalDNSServerOptions + Interface string `json:"interface,omitempty"` +} diff --git a/option/dns_record.go b/option/dns_record.go new file mode 100644 index 00000000..f10e03d9 --- /dev/null +++ b/option/dns_record.go @@ -0,0 +1,125 @@ +package option + +import ( + "encoding/base64" + "strings" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + M "github.com/sagernet/sing/common/metadata" + + "github.com/miekg/dns" +) + +const defaultDNSRecordTTL uint32 = 3600 + +type DNSRCode int + +func (r DNSRCode) MarshalJSON() ([]byte, error) { + rCodeValue, loaded := dns.RcodeToString[int(r)] + if loaded { + return json.Marshal(rCodeValue) + } + return json.Marshal(int(r)) +} + +func (r *DNSRCode) UnmarshalJSON(bytes []byte) error { + var intValue int + err := json.Unmarshal(bytes, &intValue) + if err == nil { + *r = DNSRCode(intValue) + return nil + } + var stringValue string + err = json.Unmarshal(bytes, &stringValue) + if err != nil { + return err + } + rCodeValue, loaded := dns.StringToRcode[stringValue] + if !loaded { + return E.New("unknown rcode: " + stringValue) + } + *r = DNSRCode(rCodeValue) + return nil +} + +func (r *DNSRCode) Build() int { + if r == nil { + return dns.RcodeSuccess + } + return int(*r) +} + +type DNSRecordOptions struct { + dns.RR + fromBase64 bool +} + +func (o DNSRecordOptions) MarshalJSON() ([]byte, error) { + if o.fromBase64 { + buffer := buf.Get(dns.Len(o.RR)) + defer buf.Put(buffer) + offset, err := dns.PackRR(o.RR, buffer, 0, nil, false) + if err != nil { + return nil, err + } + return json.Marshal(base64.StdEncoding.EncodeToString(buffer[:offset])) + } + return json.Marshal(o.RR.String()) +} + +func (o *DNSRecordOptions) UnmarshalJSON(data []byte) error { + var stringValue string + err := json.Unmarshal(data, &stringValue) + if err != nil { + return err + } + binary, err := base64.StdEncoding.DecodeString(stringValue) + if err == nil { + return o.unmarshalBase64(binary) + } + record, err := parseDNSRecord(stringValue) + if err != nil { + return err + } + if record == nil { + return E.New("empty DNS record") + } + if a, isA := record.(*dns.A); isA { + a.A = M.AddrFromIP(a.A).Unmap().AsSlice() + } + o.RR = record + return nil +} + +func parseDNSRecord(stringValue string) (dns.RR, error) { + if len(stringValue) > 0 && stringValue[len(stringValue)-1] != '\n' { + stringValue += "\n" + } + parser := dns.NewZoneParser(strings.NewReader(stringValue), "", "") + parser.SetDefaultTTL(defaultDNSRecordTTL) + record, _ := parser.Next() + return record, parser.Err() +} + +func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error { + record, _, err := dns.UnpackRR(binary, 0) + if err != nil { + return E.New("parse binary DNS record") + } + o.RR = record + o.fromBase64 = true + return nil +} + +func (o DNSRecordOptions) Build() dns.RR { + return o.RR +} + +func (o DNSRecordOptions) Match(record dns.RR) bool { + if o.RR == nil || record == nil { + return false + } + return dns.IsDuplicate(o.RR, record) +} diff --git a/option/dns_record_test.go b/option/dns_record_test.go new file mode 100644 index 00000000..759ef5fc --- /dev/null +++ b/option/dns_record_test.go @@ -0,0 +1,40 @@ +package option + +import ( + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func mustRecordOptions(t *testing.T, record string) DNSRecordOptions { + t.Helper() + var value DNSRecordOptions + require.NoError(t, value.UnmarshalJSON([]byte(`"`+record+`"`))) + return value +} + +func TestDNSRecordOptionsUnmarshalJSONRejectsRelativeNames(t *testing.T) { + t.Parallel() + + for _, record := range []string{ + "@ IN A 1.1.1.1", + "www IN CNAME example.com.", + "example.com. IN CNAME @", + "example.com. IN CNAME www", + } { + var value DNSRecordOptions + err := value.UnmarshalJSON([]byte(`"` + record + `"`)) + require.Error(t, err) + } +} + +func TestDNSRecordOptionsMatchIgnoresTTL(t *testing.T) { + t.Parallel() + + expected := mustRecordOptions(t, "example.com. 600 IN A 1.1.1.1") + record, err := dns.NewRR("example.com. 60 IN A 1.1.1.1") + require.NoError(t, err) + + require.True(t, expected.Match(record)) +} diff --git a/option/dns_test.go b/option/dns_test.go new file mode 100644 index 00000000..4e7bf9a9 --- /dev/null +++ b/option/dns_test.go @@ -0,0 +1,54 @@ +package option + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + + "github.com/stretchr/testify/require" +) + +type stubDNSTransportOptionsRegistry struct{} + +func (stubDNSTransportOptionsRegistry) CreateOptions(transportType string) (any, bool) { + switch transportType { + case C.DNSTypeUDP: + return new(RemoteDNSServerOptions), true + case C.DNSTypeFakeIP: + return new(FakeIPDNSServerOptions), true + default: + return nil, false + } +} + +func TestDNSOptionsRejectsLegacyFakeIPOptions(t *testing.T) { + t.Parallel() + + ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) + var options DNSOptions + err := json.UnmarshalContext(ctx, []byte(`{ + "fakeip": { + "enabled": true, + "inet4_range": "198.18.0.0/15" + } + }`), &options) + require.EqualError(t, err, legacyDNSFakeIPRemovedMessage) +} + +func TestDNSServerOptionsRejectsLegacyFormats(t *testing.T) { + t.Parallel() + + ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) + testCases := []string{ + `{"address":"1.1.1.1"}`, + `{"type":"legacy","address":"1.1.1.1"}`, + } + for _, content := range testCases { + var options DNSServerOptions + err := json.UnmarshalContext(ctx, []byte(content), &options) + require.EqualError(t, err, legacyDNSServerRemovedMessage) + } +} diff --git a/option/endpoint.go b/option/endpoint.go new file mode 100644 index 00000000..45c4f831 --- /dev/null +++ b/option/endpoint.go @@ -0,0 +1,47 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/service" +) + +type EndpointOptionsRegistry interface { + CreateOptions(endpointType string) (any, bool) +} + +type _Endpoint struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Endpoint _Endpoint + +func (h *Endpoint) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Endpoint)(h), h.Options) +} + +func (h *Endpoint) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Endpoint)(h)) + if err != nil { + return err + } + registry := service.FromContext[EndpointOptionsRegistry](ctx) + if registry == nil { + return E.New("missing endpoint fields registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown endpoint type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Endpoint)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} diff --git a/option/experimental.go b/option/experimental.go new file mode 100644 index 00000000..2f00decf --- /dev/null +++ b/option/experimental.go @@ -0,0 +1,55 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type ExperimentalOptions struct { + CacheFile *CacheFileOptions `json:"cache_file,omitempty"` + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` + V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + Debug *DebugOptions `json:"debug,omitempty"` +} + +type CacheFileOptions struct { + Enabled bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + CacheID string `json:"cache_id,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty"` + StoreRDRC bool `json:"store_rdrc,omitempty"` + RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` + StoreDNS bool `json:"store_dns,omitempty"` +} + +type ClashAPIOptions struct { + ExternalController string `json:"external_controller,omitempty"` + ExternalUI string `json:"external_ui,omitempty"` + ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` + ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` + Secret string `json:"secret,omitempty"` + DefaultMode string `json:"default_mode,omitempty"` + ModeList []string `json:"-"` + AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` + AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` + + // Deprecated: migrated to global cache file + CacheFile string `json:"cache_file,omitempty"` + // Deprecated: migrated to global cache file + CacheID string `json:"cache_id,omitempty"` + // Deprecated: migrated to global cache file + StoreMode bool `json:"store_mode,omitempty"` + // Deprecated: migrated to global cache file + StoreSelected bool `json:"store_selected,omitempty"` + // Deprecated: migrated to global cache file + StoreFakeIP bool `json:"store_fakeip,omitempty"` +} + +type V2RayAPIOptions struct { + Listen string `json:"listen,omitempty"` + Stats *V2RayStatsServiceOptions `json:"stats,omitempty"` +} + +type V2RayStatsServiceOptions struct { + Enabled bool `json:"enabled,omitempty"` + Inbounds []string `json:"inbounds,omitempty"` + Outbounds []string `json:"outbounds,omitempty"` + Users []string `json:"users,omitempty"` +} diff --git a/option/group.go b/option/group.go new file mode 100644 index 00000000..02b3a5ec --- /dev/null +++ b/option/group.go @@ -0,0 +1,18 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type SelectorOutboundOptions struct { + Outbounds []string `json:"outbounds"` + Default string `json:"default,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` +} + +type URLTestOutboundOptions struct { + Outbounds []string `json:"outbounds"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Tolerance uint16 `json:"tolerance,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` +} diff --git a/option/hysteria.go b/option/hysteria.go new file mode 100644 index 00000000..18675901 --- /dev/null +++ b/option/hysteria.go @@ -0,0 +1,46 @@ +package option + +import ( + "github.com/sagernet/sing/common/byteformats" + "github.com/sagernet/sing/common/json/badoption" +) + +type HysteriaInboundOptions struct { + ListenOptions + Up *byteformats.NetworkBytesCompat `json:"up,omitempty"` + UpMbps int `json:"up_mbps,omitempty"` + Down *byteformats.NetworkBytesCompat `json:"down,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` + Obfs string `json:"obfs,omitempty"` + Users []HysteriaUser `json:"users,omitempty"` + ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty"` + ReceiveWindowClient uint64 `json:"recv_window_client,omitempty"` + MaxConnClient int `json:"max_conn_client,omitempty"` + DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty"` + InboundTLSOptionsContainer +} + +type HysteriaUser struct { + Name string `json:"name,omitempty"` + Auth []byte `json:"auth,omitempty"` + AuthString string `json:"auth_str,omitempty"` +} + +type HysteriaOutboundOptions struct { + DialerOptions + ServerOptions + ServerPorts badoption.Listable[string] `json:"server_ports,omitempty"` + HopInterval badoption.Duration `json:"hop_interval,omitempty"` + Up *byteformats.NetworkBytesCompat `json:"up,omitempty"` + UpMbps int `json:"up_mbps,omitempty"` + Down *byteformats.NetworkBytesCompat `json:"down,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` + Obfs string `json:"obfs,omitempty"` + Auth []byte `json:"auth,omitempty"` + AuthString string `json:"auth_str,omitempty"` + ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty"` + ReceiveWindow uint64 `json:"recv_window,omitempty"` + DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty"` + Network NetworkList `json:"network,omitempty"` + OutboundTLSOptionsContainer +} diff --git a/option/hysteria2.go b/option/hysteria2.go new file mode 100644 index 00000000..e31c8de3 --- /dev/null +++ b/option/hysteria2.go @@ -0,0 +1,127 @@ +package option + +import ( + "net/url" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type Hysteria2InboundOptions struct { + ListenOptions + UpMbps int `json:"up_mbps,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` + Obfs *Hysteria2Obfs `json:"obfs,omitempty"` + Users []Hysteria2User `json:"users,omitempty"` + IgnoreClientBandwidth bool `json:"ignore_client_bandwidth,omitempty"` + InboundTLSOptionsContainer + Masquerade *Hysteria2Masquerade `json:"masquerade,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty"` + BrutalDebug bool `json:"brutal_debug,omitempty"` +} + +type Hysteria2Obfs struct { + Type string `json:"type,omitempty"` + Password string `json:"password,omitempty"` +} + +type Hysteria2User struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` +} + +type _Hysteria2Masquerade struct { + Type string `json:"type,omitempty"` + FileOptions Hysteria2MasqueradeFile `json:"-"` + ProxyOptions Hysteria2MasqueradeProxy `json:"-"` + StringOptions Hysteria2MasqueradeString `json:"-"` +} + +type Hysteria2Masquerade _Hysteria2Masquerade + +func (m Hysteria2Masquerade) MarshalJSON() ([]byte, error) { + var v any + switch m.Type { + case C.Hysterai2MasqueradeTypeFile: + v = m.FileOptions + case C.Hysterai2MasqueradeTypeProxy: + v = m.ProxyOptions + case C.Hysterai2MasqueradeTypeString: + v = m.StringOptions + default: + return nil, E.New("unknown masquerade type: ", m.Type) + } + return badjson.MarshallObjects((_Hysteria2Masquerade)(m), v) +} + +func (m *Hysteria2Masquerade) UnmarshalJSON(bytes []byte) error { + var urlString string + err := json.Unmarshal(bytes, &urlString) + if err == nil { + masqueradeURL, err := url.Parse(urlString) + if err != nil { + return E.Cause(err, "invalid masquerade URL") + } + switch masqueradeURL.Scheme { + case "file": + m.Type = C.Hysterai2MasqueradeTypeFile + m.FileOptions.Directory = masqueradeURL.Path + case "http", "https": + m.Type = C.Hysterai2MasqueradeTypeProxy + m.ProxyOptions.URL = urlString + default: + return E.New("unknown masquerade URL scheme: ", masqueradeURL.Scheme) + } + return nil + } + err = json.Unmarshal(bytes, (*_Hysteria2Masquerade)(m)) + if err != nil { + return err + } + var v any + switch m.Type { + case C.Hysterai2MasqueradeTypeFile: + v = &m.FileOptions + case C.Hysterai2MasqueradeTypeProxy: + v = &m.ProxyOptions + case C.Hysterai2MasqueradeTypeString: + v = &m.StringOptions + default: + return E.New("unknown masquerade type: ", m.Type) + } + return badjson.UnmarshallExcluded(bytes, (*_Hysteria2Masquerade)(m), v) +} + +type Hysteria2MasqueradeFile struct { + Directory string `json:"directory"` +} + +type Hysteria2MasqueradeProxy struct { + URL string `json:"url"` + RewriteHost bool `json:"rewrite_host,omitempty"` +} + +type Hysteria2MasqueradeString struct { + StatusCode int `json:"status_code,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + Content string `json:"content"` +} + +type Hysteria2OutboundOptions struct { + DialerOptions + ServerOptions + ServerPorts badoption.Listable[string] `json:"server_ports,omitempty"` + HopInterval badoption.Duration `json:"hop_interval,omitempty"` + HopIntervalMax badoption.Duration `json:"hop_interval_max,omitempty"` + UpMbps int `json:"up_mbps,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` + Obfs *Hysteria2Obfs `json:"obfs,omitempty"` + Password string `json:"password,omitempty"` + Network NetworkList `json:"network,omitempty"` + OutboundTLSOptionsContainer + BBRProfile string `json:"bbr_profile,omitempty"` + BrutalDebug bool `json:"brutal_debug,omitempty"` +} diff --git a/option/inbound.go b/option/inbound.go new file mode 100644 index 00000000..21497a3f --- /dev/null +++ b/option/inbound.go @@ -0,0 +1,117 @@ +package option + +import ( + "context" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/service" +) + +type InboundOptionsRegistry interface { + CreateOptions(outboundType string) (any, bool) +} + +type _Inbound struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Inbound _Inbound + +func (h *Inbound) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Inbound)(h), h.Options) +} + +func (h *Inbound) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Inbound)(h)) + if err != nil { + return err + } + registry := service.FromContext[InboundOptionsRegistry](ctx) + if registry == nil { + return E.New("missing inbound fields registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown inbound type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Inbound)(h), options) + if err != nil { + return err + } + if listenWrapper, isListen := options.(ListenOptionsWrapper); isListen { + //nolint:staticcheck + if listenWrapper.TakeListenOptions().InboundOptions != (InboundOptions{}) { + return E.New("legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-legacy-inbound-fields-to-rule-actions") + } + } + h.Options = options + return nil +} + +// Deprecated: Use rule action instead +type InboundOptions struct { + SniffEnabled bool `json:"sniff,omitempty"` + SniffOverrideDestination bool `json:"sniff_override_destination,omitempty"` + SniffTimeout badoption.Duration `json:"sniff_timeout,omitempty"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` + UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty"` +} + +type ListenOptions struct { + Listen *badoption.Addr `json:"listen,omitempty"` + ListenPort uint16 `json:"listen_port,omitempty"` + BindInterface string `json:"bind_interface,omitempty"` + RoutingMark FwMark `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + NetNs string `json:"netns,omitempty"` + DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` + TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` + Detour string `json:"detour,omitempty"` + + // Deprecated: removed + ProxyProtocol bool `json:"proxy_protocol,omitempty"` + // Deprecated: removed + ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` + InboundOptions +} + +type UDPTimeoutCompat badoption.Duration + +func (c UDPTimeoutCompat) MarshalJSON() ([]byte, error) { + return json.Marshal((time.Duration)(c).String()) +} + +func (c *UDPTimeoutCompat) UnmarshalJSON(data []byte) error { + var valueNumber int64 + err := json.Unmarshal(data, &valueNumber) + if err == nil { + *c = UDPTimeoutCompat(time.Second * time.Duration(valueNumber)) + return nil + } + return json.Unmarshal(data, (*badoption.Duration)(c)) +} + +type ListenOptionsWrapper interface { + TakeListenOptions() ListenOptions + ReplaceListenOptions(options ListenOptions) +} + +func (o *ListenOptions) TakeListenOptions() ListenOptions { + return *o +} + +func (o *ListenOptions) ReplaceListenOptions(options ListenOptions) { + *o = options +} diff --git a/option/multiplex.go b/option/multiplex.go new file mode 100644 index 00000000..309d8bdc --- /dev/null +++ b/option/multiplex.go @@ -0,0 +1,23 @@ +package option + +type InboundMultiplexOptions struct { + Enabled bool `json:"enabled,omitempty"` + Padding bool `json:"padding,omitempty"` + Brutal *BrutalOptions `json:"brutal,omitempty"` +} + +type OutboundMultiplexOptions struct { + Enabled bool `json:"enabled,omitempty"` + Protocol string `json:"protocol,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + MinStreams int `json:"min_streams,omitempty"` + MaxStreams int `json:"max_streams,omitempty"` + Padding bool `json:"padding,omitempty"` + Brutal *BrutalOptions `json:"brutal,omitempty"` +} + +type BrutalOptions struct { + Enabled bool `json:"enabled,omitempty"` + UpMbps int `json:"up_mbps,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` +} diff --git a/option/naive.go b/option/naive.go new file mode 100644 index 00000000..da3a88db --- /dev/null +++ b/option/naive.go @@ -0,0 +1,40 @@ +package option + +import ( + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/byteformats" + "github.com/sagernet/sing/common/json/badoption" +) + +type QuicheCongestionControl string + +const ( + QuicheCongestionControlDefault QuicheCongestionControl = "" + QuicheCongestionControlBBR QuicheCongestionControl = "TBBR" + QuicheCongestionControlBBRv2 QuicheCongestionControl = "B2ON" + QuicheCongestionControlCubic QuicheCongestionControl = "QBIC" + QuicheCongestionControlReno QuicheCongestionControl = "RENO" +) + +type NaiveInboundOptions struct { + ListenOptions + Users []auth.User `json:"users,omitempty"` + Network NetworkList `json:"network,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + InboundTLSOptionsContainer +} + +type NaiveOutboundOptions struct { + DialerOptions + ServerOptions + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + InsecureConcurrency int `json:"insecure_concurrency,omitempty"` + ExtraHeaders badoption.HTTPHeader `json:"extra_headers,omitempty"` + ReceiveWindow *byteformats.MemoryBytes `json:"stream_receive_window,omitempty"` + UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` + QUIC bool `json:"quic,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + QUICSessionReceiveWindow *byteformats.MemoryBytes `json:"quic_session_receive_window,omitempty"` + OutboundTLSOptionsContainer +} diff --git a/option/ntp.go b/option/ntp.go new file mode 100644 index 00000000..d441d95e --- /dev/null +++ b/option/ntp.go @@ -0,0 +1,11 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type NTPOptions struct { + Enabled bool `json:"enabled,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + WriteToSystem bool `json:"write_to_system,omitempty"` + ServerOptions + DialerOptions +} diff --git a/option/ocm.go b/option/ocm.go new file mode 100644 index 00000000..c13a1c1f --- /dev/null +++ b/option/ocm.go @@ -0,0 +1,20 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type OCMServiceOptions struct { + ListenOptions + InboundTLSOptionsContainer + CredentialPath string `json:"credential_path,omitempty"` + Users []OCMUser `json:"users,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + Detour string `json:"detour,omitempty"` + UsagesPath string `json:"usages_path,omitempty"` +} + +type OCMUser struct { + Name string `json:"name,omitempty"` + Token string `json:"token,omitempty"` +} diff --git a/option/oom_killer.go b/option/oom_killer.go new file mode 100644 index 00000000..1183b502 --- /dev/null +++ b/option/oom_killer.go @@ -0,0 +1,15 @@ +package option + +import ( + "github.com/sagernet/sing/common/byteformats" + "github.com/sagernet/sing/common/json/badoption" +) + +type OOMKillerServiceOptions struct { + MemoryLimit *byteformats.MemoryBytes `json:"memory_limit,omitempty"` + SafetyMargin *byteformats.MemoryBytes `json:"safety_margin,omitempty"` + MinInterval badoption.Duration `json:"min_interval,omitempty"` + MaxInterval badoption.Duration `json:"max_interval,omitempty"` + KillerDisabled bool `json:"-"` + MemoryLimitOverride uint64 `json:"-"` +} diff --git a/option/options.go b/option/options.go new file mode 100644 index 00000000..a08dcbc0 --- /dev/null +++ b/option/options.go @@ -0,0 +1,120 @@ +package option + +import ( + "bytes" + "context" + + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" +) + +type _Options struct { + RawMessage json.RawMessage `json:"-"` + Schema string `json:"$schema,omitempty"` + Log *LogOptions `json:"log,omitempty"` + DNS *DNSOptions `json:"dns,omitempty"` + NTP *NTPOptions `json:"ntp,omitempty"` + Certificate *CertificateOptions `json:"certificate,omitempty"` + CertificateProviders []CertificateProvider `json:"certificate_providers,omitempty"` + Endpoints []Endpoint `json:"endpoints,omitempty"` + Inbounds []Inbound `json:"inbounds,omitempty"` + Outbounds []Outbound `json:"outbounds,omitempty"` + Route *RouteOptions `json:"route,omitempty"` + Services []Service `json:"services,omitempty"` + Experimental *ExperimentalOptions `json:"experimental,omitempty"` +} + +type Options _Options + +func (o *Options) UnmarshalJSONContext(ctx context.Context, content []byte) error { + decoder := json.NewDecoderContext(ctx, bytes.NewReader(content)) + decoder.DisallowUnknownFields() + err := decoder.Decode((*_Options)(o)) + if err != nil { + return err + } + o.RawMessage = content + return checkOptions(o) +} + +type LogOptions struct { + Disabled bool `json:"disabled,omitempty"` + Level string `json:"level,omitempty"` + Output string `json:"output,omitempty"` + Timestamp bool `json:"timestamp,omitempty"` + DisableColor bool `json:"-"` +} + +type StubOptions struct{} + +func checkOptions(options *Options) error { + err := checkInbounds(options.Inbounds) + if err != nil { + return err + } + err = checkOutbounds(options.Outbounds, options.Endpoints) + if err != nil { + return err + } + err = checkCertificateProviders(options.CertificateProviders) + if err != nil { + return err + } + return nil +} + +func checkCertificateProviders(providers []CertificateProvider) error { + seen := make(map[string]bool) + for i, provider := range providers { + tag := provider.Tag + if tag == "" { + tag = F.ToString(i) + } + if seen[tag] { + return E.New("duplicate certificate provider tag: ", tag) + } + seen[tag] = true + } + return nil +} + +func checkInbounds(inbounds []Inbound) error { + seen := make(map[string]bool) + for i, inbound := range inbounds { + tag := inbound.Tag + if tag == "" { + tag = F.ToString(i) + } + if seen[tag] { + return E.New("duplicate inbound tag: ", tag) + } + seen[tag] = true + } + return nil +} + +func checkOutbounds(outbounds []Outbound, endpoints []Endpoint) error { + seen := make(map[string]bool) + for i, outbound := range outbounds { + tag := outbound.Tag + if tag == "" { + tag = F.ToString(i) + } + if seen[tag] { + return E.New("duplicate outbound/endpoint tag: ", tag) + } + seen[tag] = true + } + for i, endpoint := range endpoints { + tag := endpoint.Tag + if tag == "" { + tag = F.ToString(i) + } + if seen[tag] { + return E.New("duplicate outbound/endpoint tag: ", tag) + } + seen[tag] = true + } + return nil +} diff --git a/option/origin_ca.go b/option/origin_ca.go new file mode 100644 index 00000000..ee8b3704 --- /dev/null +++ b/option/origin_ca.go @@ -0,0 +1,76 @@ +package option + +import ( + "strings" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +type CloudflareOriginCACertificateProviderOptions struct { + Domain badoption.Listable[string] `json:"domain,omitempty"` + DataDirectory string `json:"data_directory,omitempty"` + APIToken string `json:"api_token,omitempty"` + OriginCAKey string `json:"origin_ca_key,omitempty"` + RequestType CloudflareOriginCARequestType `json:"request_type,omitempty"` + RequestedValidity CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"` + Detour string `json:"detour,omitempty"` +} + +type CloudflareOriginCARequestType string + +const ( + CloudflareOriginCARequestTypeOriginRSA = CloudflareOriginCARequestType("origin-rsa") + CloudflareOriginCARequestTypeOriginECC = CloudflareOriginCARequestType("origin-ecc") +) + +func (t *CloudflareOriginCARequestType) UnmarshalJSON(data []byte) error { + var value string + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + value = strings.ToLower(value) + switch CloudflareOriginCARequestType(value) { + case "", CloudflareOriginCARequestTypeOriginRSA, CloudflareOriginCARequestTypeOriginECC: + *t = CloudflareOriginCARequestType(value) + default: + return E.New("unsupported Cloudflare Origin CA request type: ", value) + } + return nil +} + +type CloudflareOriginCARequestValidity uint16 + +const ( + CloudflareOriginCARequestValidity7 = CloudflareOriginCARequestValidity(7) + CloudflareOriginCARequestValidity30 = CloudflareOriginCARequestValidity(30) + CloudflareOriginCARequestValidity90 = CloudflareOriginCARequestValidity(90) + CloudflareOriginCARequestValidity365 = CloudflareOriginCARequestValidity(365) + CloudflareOriginCARequestValidity730 = CloudflareOriginCARequestValidity(730) + CloudflareOriginCARequestValidity1095 = CloudflareOriginCARequestValidity(1095) + CloudflareOriginCARequestValidity5475 = CloudflareOriginCARequestValidity(5475) +) + +func (v *CloudflareOriginCARequestValidity) UnmarshalJSON(data []byte) error { + var value uint16 + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + switch CloudflareOriginCARequestValidity(value) { + case 0, + CloudflareOriginCARequestValidity7, + CloudflareOriginCARequestValidity30, + CloudflareOriginCARequestValidity90, + CloudflareOriginCARequestValidity365, + CloudflareOriginCARequestValidity730, + CloudflareOriginCARequestValidity1095, + CloudflareOriginCARequestValidity5475: + *v = CloudflareOriginCARequestValidity(value) + default: + return E.New("unsupported Cloudflare Origin CA requested validity: ", value) + } + return nil +} diff --git a/option/outbound.go b/option/outbound.go new file mode 100644 index 00000000..d8fcb822 --- /dev/null +++ b/option/outbound.go @@ -0,0 +1,169 @@ +package option + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/service" +) + +type OutboundOptionsRegistry interface { + CreateOptions(outboundType string) (any, bool) +} + +type _Outbound struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Outbound _Outbound + +func (h *Outbound) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Outbound)(h), h.Options) +} + +func (h *Outbound) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Outbound)(h)) + if err != nil { + return err + } + registry := service.FromContext[OutboundOptionsRegistry](ctx) + if registry == nil { + return E.New("missing outbound options registry in context") + } + switch h.Type { + case C.TypeDNS: + return E.New("dns outbound is deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, use rule actions instead") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown outbound type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Outbound)(h), options) + if err != nil { + return err + } + if listenWrapper, isListen := options.(ListenOptionsWrapper); isListen { + //nolint:staticcheck + if listenWrapper.TakeListenOptions().InboundOptions != (InboundOptions{}) { + return E.New("legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, use rule actions instead") + } + } + h.Options = options + return nil +} + +type DialerOptionsWrapper interface { + TakeDialerOptions() DialerOptions + ReplaceDialerOptions(options DialerOptions) +} + +type DialerOptions struct { + Detour string `json:"detour,omitempty"` + BindInterface string `json:"bind_interface,omitempty"` + Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` + BindAddressNoPort bool `json:"bind_address_no_port,omitempty"` + ProtectPath string `json:"protect_path,omitempty"` + RoutingMark FwMark `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + NetNs string `json:"netns,omitempty"` + ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` + TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` + FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` + + // Deprecated: migrated to domain resolver + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` +} + +type _DomainResolveOptions struct { + Server string `json:"server"` + Strategy DomainStrategy `json:"strategy,omitempty"` + DisableCache bool `json:"disable_cache,omitempty"` + DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` + RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` + ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` +} + +type DomainResolveOptions _DomainResolveOptions + +func (o DomainResolveOptions) MarshalJSON() ([]byte, error) { + if o.Server == "" { + return []byte("{}"), nil + } else if o.Strategy == DomainStrategy(C.DomainStrategyAsIS) && + !o.DisableCache && + !o.DisableOptimisticCache && + o.RewriteTTL == nil && + o.ClientSubnet == nil { + return json.Marshal(o.Server) + } else { + return json.Marshal((_DomainResolveOptions)(o)) + } +} + +func (o *DomainResolveOptions) UnmarshalJSON(bytes []byte) error { + var stringValue string + err := json.Unmarshal(bytes, &stringValue) + if err == nil { + o.Server = stringValue + return nil + } + err = json.Unmarshal(bytes, (*_DomainResolveOptions)(o)) + if err != nil { + return err + } + if o.Server == "" { + return E.New("empty domain_resolver.server") + } + return nil +} + +func (o *DialerOptions) TakeDialerOptions() DialerOptions { + return *o +} + +func (o *DialerOptions) ReplaceDialerOptions(options DialerOptions) { + *o = options +} + +type ServerOptionsWrapper interface { + TakeServerOptions() ServerOptions + ReplaceServerOptions(options ServerOptions) +} + +type ServerOptions struct { + Server string `json:"server"` + ServerPort uint16 `json:"server_port"` +} + +func (o ServerOptions) Build() M.Socksaddr { + return M.ParseSocksaddrHostPort(o.Server, o.ServerPort) +} + +func (o ServerOptions) ServerIsDomain() bool { + return o.Build().IsDomain() +} + +func (o *ServerOptions) TakeServerOptions() ServerOptions { + return *o +} + +func (o *ServerOptions) ReplaceServerOptions(options ServerOptions) { + *o = options +} diff --git a/option/platform.go b/option/platform.go new file mode 100644 index 00000000..e4ecd6fa --- /dev/null +++ b/option/platform.go @@ -0,0 +1,105 @@ +package option + +import ( + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +type OnDemandOptions struct { + Enabled bool `json:"enabled,omitempty"` + Rules []OnDemandRule `json:"rules,omitempty"` +} + +type OnDemandRule struct { + Action *OnDemandRuleAction `json:"action,omitempty"` + DNSSearchDomainMatch badoption.Listable[string] `json:"dns_search_domain_match,omitempty"` + DNSServerAddressMatch badoption.Listable[string] `json:"dns_server_address_match,omitempty"` + InterfaceTypeMatch *OnDemandRuleInterfaceType `json:"interface_type_match,omitempty"` + SSIDMatch badoption.Listable[string] `json:"ssid_match,omitempty"` + ProbeURL string `json:"probe_url,omitempty"` +} + +type OnDemandRuleAction int + +func (r *OnDemandRuleAction) MarshalJSON() ([]byte, error) { + if r == nil { + return nil, nil + } + value := *r + var actionName string + switch value { + case 1: + actionName = "connect" + case 2: + actionName = "disconnect" + case 3: + actionName = "evaluate_connection" + default: + return nil, E.New("unknown action: ", value) + } + return json.Marshal(actionName) +} + +func (r *OnDemandRuleAction) UnmarshalJSON(bytes []byte) error { + var actionName string + if err := json.Unmarshal(bytes, &actionName); err != nil { + return err + } + var actionValue int + switch actionName { + case "connect": + actionValue = 1 + case "disconnect": + actionValue = 2 + case "evaluate_connection": + actionValue = 3 + case "ignore": + actionValue = 4 + default: + return E.New("unknown action name: ", actionName) + } + *r = OnDemandRuleAction(actionValue) + return nil +} + +type OnDemandRuleInterfaceType int + +func (r *OnDemandRuleInterfaceType) MarshalJSON() ([]byte, error) { + if r == nil { + return nil, nil + } + value := *r + var interfaceTypeName string + switch value { + case 1: + interfaceTypeName = "any" + case 2: + interfaceTypeName = "wifi" + case 3: + interfaceTypeName = "cellular" + default: + return nil, E.New("unknown interface type: ", value) + } + return json.Marshal(interfaceTypeName) +} + +func (r *OnDemandRuleInterfaceType) UnmarshalJSON(bytes []byte) error { + var interfaceTypeName string + if err := json.Unmarshal(bytes, &interfaceTypeName); err != nil { + return err + } + var interfaceTypeValue int + switch interfaceTypeName { + case "any": + interfaceTypeValue = 1 + case "wifi": + interfaceTypeValue = 2 + case "cellular": + interfaceTypeValue = 3 + default: + return E.New("unknown interface type name: ", interfaceTypeName) + } + *r = OnDemandRuleInterfaceType(interfaceTypeValue) + return nil +} diff --git a/option/redir.go b/option/redir.go new file mode 100644 index 00000000..743a6e10 --- /dev/null +++ b/option/redir.go @@ -0,0 +1,10 @@ +package option + +type RedirectInboundOptions struct { + ListenOptions +} + +type TProxyInboundOptions struct { + ListenOptions + Network NetworkList `json:"network,omitempty"` +} diff --git a/option/resolved.go b/option/resolved.go new file mode 100644 index 00000000..cb9f579d --- /dev/null +++ b/option/resolved.go @@ -0,0 +1,49 @@ +package option + +import ( + "context" + "net/netip" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +type _ResolvedServiceOptions struct { + ListenOptions +} + +type ResolvedServiceOptions _ResolvedServiceOptions + +func (r ResolvedServiceOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { + if r.Listen != nil && netip.Addr(*r.Listen) == (netip.AddrFrom4([4]byte{127, 0, 0, 53})) { + r.Listen = nil + } + if r.ListenPort == 53 { + r.ListenPort = 0 + } + return json.MarshalContext(ctx, (*_ResolvedServiceOptions)(&r)) +} + +func (r *ResolvedServiceOptions) UnmarshalJSONContext(ctx context.Context, bytes []byte) error { + err := json.UnmarshalContextDisallowUnknownFields(ctx, bytes, (*_ResolvedServiceOptions)(r)) + if err != nil { + return err + } + if r.Listen == nil { + r.Listen = (*badoption.Addr)(common.Ptr(netip.AddrFrom4([4]byte{127, 0, 0, 53}))) + } + if r.ListenPort == 0 { + r.ListenPort = 53 + } + return nil +} + +type ResolvedDNSServerOptions struct { + Service string `json:"service"` + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` + // NDots int `json:"ndots,omitempty"` + // Timeout badoption.Duration `json:"timeout,omitempty"` + // Attempts int `json:"attempts,omitempty"` + // Rotate bool `json:"rotate,omitempty"` +} diff --git a/option/route.go b/option/route.go new file mode 100644 index 00000000..0c3e576d --- /dev/null +++ b/option/route.go @@ -0,0 +1,35 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type RouteOptions struct { + GeoIP *GeoIPOptions `json:"geoip,omitempty"` + Geosite *GeositeOptions `json:"geosite,omitempty"` + Rules []Rule `json:"rules,omitempty"` + RuleSet []RuleSet `json:"rule_set,omitempty"` + Final string `json:"final,omitempty"` + FindProcess bool `json:"find_process,omitempty"` + FindNeighbor bool `json:"find_neighbor,omitempty"` + DHCPLeaseFiles badoption.Listable[string] `json:"dhcp_lease_files,omitempty"` + AutoDetectInterface bool `json:"auto_detect_interface,omitempty"` + OverrideAndroidVPN bool `json:"override_android_vpn,omitempty"` + DefaultInterface string `json:"default_interface,omitempty"` + DefaultMark FwMark `json:"default_mark,omitempty"` + DefaultDomainResolver *DomainResolveOptions `json:"default_domain_resolver,omitempty"` + DefaultNetworkStrategy *NetworkStrategy `json:"default_network_strategy,omitempty"` + DefaultNetworkType badoption.Listable[InterfaceType] `json:"default_network_type,omitempty"` + DefaultFallbackNetworkType badoption.Listable[InterfaceType] `json:"default_fallback_network_type,omitempty"` + DefaultFallbackDelay badoption.Duration `json:"default_fallback_delay,omitempty"` +} + +type GeoIPOptions struct { + Path string `json:"path,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + DownloadDetour string `json:"download_detour,omitempty"` +} + +type GeositeOptions struct { + Path string `json:"path,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + DownloadDetour string `json:"download_detour,omitempty"` +} diff --git a/option/rule.go b/option/rule.go new file mode 100644 index 00000000..5759cf56 --- /dev/null +++ b/option/rule.go @@ -0,0 +1,223 @@ +package option + +import ( + "context" + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type _Rule struct { + Type string `json:"type,omitempty"` + DefaultOptions DefaultRule `json:"-"` + LogicalOptions LogicalRule `json:"-"` +} + +type Rule _Rule + +func (r Rule) MarshalJSON() ([]byte, error) { + var v any + switch r.Type { + case C.RuleTypeDefault: + r.Type = "" + v = r.DefaultOptions + case C.RuleTypeLogical: + v = r.LogicalOptions + default: + return nil, E.New("unknown rule type: " + r.Type) + } + return badjson.MarshallObjects((_Rule)(r), v) +} + +func (r *Rule) UnmarshalJSONContext(ctx context.Context, bytes []byte) error { + err := json.UnmarshalContext(ctx, bytes, (*_Rule)(r)) + if err != nil { + return err + } + payload, err := rulePayloadWithoutType(ctx, bytes) + if err != nil { + return err + } + switch r.Type { + case "", C.RuleTypeDefault: + r.Type = C.RuleTypeDefault + return unmarshalDefaultRuleContext(ctx, payload, &r.DefaultOptions) + case C.RuleTypeLogical: + return unmarshalLogicalRuleContext(ctx, payload, &r.LogicalOptions) + default: + return E.New("unknown rule type: " + r.Type) + } +} + +func (r Rule) IsValid() bool { + switch r.Type { + case C.RuleTypeDefault: + return r.DefaultOptions.IsValid() + case C.RuleTypeLogical: + return r.LogicalOptions.IsValid() + default: + panic("unknown rule type: " + r.Type) + } +} + +type RawDefaultRule struct { + Inbound badoption.Listable[string] `json:"inbound,omitempty"` + IPVersion int `json:"ip_version,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty"` + AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Client badoption.Listable[string] `json:"client,omitempty"` + Domain badoption.Listable[string] `json:"domain,omitempty"` + DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` + DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` + DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` + Geosite badoption.Listable[string] `json:"geosite,omitempty"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` + SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` + IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` + IPIsPrivate bool `json:"ip_is_private,omitempty"` + SourcePort badoption.Listable[uint16] `json:"source_port,omitempty"` + SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` + Port badoption.Listable[uint16] `json:"port,omitempty"` + PortRange badoption.Listable[string] `json:"port_range,omitempty"` + ProcessName badoption.Listable[string] `json:"process_name,omitempty"` + ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` + ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` + PackageName badoption.Listable[string] `json:"package_name,omitempty"` + PackageNameRegex badoption.Listable[string] `json:"package_name_regex,omitempty"` + User badoption.Listable[string] `json:"user,omitempty"` + UserID badoption.Listable[int32] `json:"user_id,omitempty"` + ClashMode string `json:"clash_mode,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` + NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` + WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` + WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` + InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"` + NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` + DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` + SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` + PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` + Invert bool `json:"invert,omitempty"` + + // Deprecated: renamed to rule_set_ip_cidr_match_source + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` +} + +type DefaultRule struct { + RawDefaultRule + RuleAction +} + +func (r DefaultRule) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(r.RawDefaultRule, r.RuleAction) +} + +func (r *DefaultRule) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, &r.RawDefaultRule) + if err != nil { + return err + } + return badjson.UnmarshallExcluded(data, &r.RawDefaultRule, &r.RuleAction) +} + +func (r DefaultRule) IsValid() bool { + var defaultValue DefaultRule + defaultValue.Invert = r.Invert + return !reflect.DeepEqual(r, defaultValue) +} + +type RawLogicalRule struct { + Mode string `json:"mode"` + Rules []Rule `json:"rules,omitempty"` + Invert bool `json:"invert,omitempty"` +} + +type LogicalRule struct { + RawLogicalRule + RuleAction +} + +func (r LogicalRule) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(r.RawLogicalRule, r.RuleAction) +} + +func (r *LogicalRule) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, &r.RawLogicalRule) + if err != nil { + return err + } + return badjson.UnmarshallExcluded(data, &r.RawLogicalRule, &r.RuleAction) +} + +func rulePayloadWithoutType(ctx context.Context, data []byte) ([]byte, error) { + var content badjson.JSONObject + err := content.UnmarshalJSONContext(ctx, data) + if err != nil { + return nil, err + } + content.Remove("type") + return content.MarshalJSONContext(ctx) +} + +func unmarshalDefaultRuleContext(ctx context.Context, data []byte, rule *DefaultRule) error { + rawAction, routeOptions, err := inspectRouteRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedRouteRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(ctx, data, &rule.RawDefaultRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &rule.RawDefaultRule, &rule.RuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (RouteActionOptions{}) { + rule.RuleAction = RuleAction{} + } + return nil +} + +func unmarshalLogicalRuleContext(ctx context.Context, data []byte, rule *LogicalRule) error { + rawAction, routeOptions, err := inspectRouteRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedRouteRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(nestedRuleChildContext(ctx), data, &rule.RawLogicalRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &rule.RawLogicalRule, &rule.RuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (RouteActionOptions{}) { + rule.RuleAction = RuleAction{} + } + return nil +} + +func (r *LogicalRule) IsValid() bool { + return len(r.Rules) > 0 && common.All(r.Rules, Rule.IsValid) +} diff --git a/option/rule_action.go b/option/rule_action.go new file mode 100644 index 00000000..c369cfeb --- /dev/null +++ b/option/rule_action.go @@ -0,0 +1,339 @@ +package option + +import ( + "context" + "fmt" + "net/netip" + "time" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type _RuleAction struct { + Action string `json:"action,omitempty"` + RouteOptions RouteActionOptions `json:"-"` + RouteOptionsOptions RouteOptionsActionOptions `json:"-"` + DirectOptions DirectActionOptions `json:"-"` + BypassOptions RouteActionOptions `json:"-"` + RejectOptions RejectActionOptions `json:"-"` + SniffOptions RouteActionSniff `json:"-"` + ResolveOptions RouteActionResolve `json:"-"` +} + +type RuleAction _RuleAction + +func (r RuleAction) MarshalJSON() ([]byte, error) { + if r.Action == "" { + return json.Marshal(struct{}{}) + } + var v any + switch r.Action { + case C.RuleActionTypeRoute: + r.Action = "" + v = r.RouteOptions + case C.RuleActionTypeRouteOptions: + v = r.RouteOptionsOptions + case C.RuleActionTypeDirect: + v = r.DirectOptions + case C.RuleActionTypeBypass: + v = r.BypassOptions + case C.RuleActionTypeReject: + v = r.RejectOptions + case C.RuleActionTypeHijackDNS: + v = nil + case C.RuleActionTypeSniff: + v = r.SniffOptions + case C.RuleActionTypeResolve: + v = r.ResolveOptions + default: + return nil, E.New("unknown rule action: " + r.Action) + } + if v == nil { + return badjson.MarshallObjects((_RuleAction)(r)) + } + return badjson.MarshallObjects((_RuleAction)(r), v) +} + +func (r *RuleAction) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*_RuleAction)(r)) + if err != nil { + return err + } + var v any + switch r.Action { + case "", C.RuleActionTypeRoute: + r.Action = C.RuleActionTypeRoute + v = &r.RouteOptions + case C.RuleActionTypeRouteOptions: + v = &r.RouteOptionsOptions + case C.RuleActionTypeDirect: + v = &r.DirectOptions + case C.RuleActionTypeBypass: + v = &r.BypassOptions + case C.RuleActionTypeReject: + v = &r.RejectOptions + case C.RuleActionTypeHijackDNS: + v = nil + case C.RuleActionTypeSniff: + v = &r.SniffOptions + case C.RuleActionTypeResolve: + v = &r.ResolveOptions + default: + return E.New("unknown rule action: " + r.Action) + } + if v == nil { + // check unknown fields + return json.UnmarshalDisallowUnknownFields(data, &_RuleAction{}) + } + err = badjson.UnmarshallExcluded(data, (*_RuleAction)(r), v) + if err != nil { + return err + } + return nil +} + +type _DNSRuleAction struct { + Action string `json:"action,omitempty"` + RouteOptions DNSRouteActionOptions `json:"-"` + RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"` + RejectOptions RejectActionOptions `json:"-"` + PredefinedOptions DNSRouteActionPredefined `json:"-"` +} + +type DNSRuleAction _DNSRuleAction + +func (r DNSRuleAction) MarshalJSON() ([]byte, error) { + if r.Action == "" { + return json.Marshal(struct{}{}) + } + var v any + switch r.Action { + case C.RuleActionTypeRoute: + r.Action = "" + v = r.RouteOptions + case C.RuleActionTypeEvaluate: + v = r.RouteOptions + case C.RuleActionTypeRespond: + v = nil + case C.RuleActionTypeRouteOptions: + v = r.RouteOptionsOptions + case C.RuleActionTypeReject: + v = r.RejectOptions + case C.RuleActionTypePredefined: + v = r.PredefinedOptions + default: + return nil, E.New("unknown DNS rule action: " + r.Action) + } + if v == nil { + return badjson.MarshallObjects((_DNSRuleAction)(r)) + } + return badjson.MarshallObjects((_DNSRuleAction)(r), v) +} + +func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) error { + err := json.Unmarshal(data, (*_DNSRuleAction)(r)) + if err != nil { + return err + } + var v any + switch r.Action { + case "", C.RuleActionTypeRoute: + r.Action = C.RuleActionTypeRoute + v = &r.RouteOptions + case C.RuleActionTypeEvaluate: + v = &r.RouteOptions + case C.RuleActionTypeRespond: + v = nil + case C.RuleActionTypeRouteOptions: + v = &r.RouteOptionsOptions + case C.RuleActionTypeReject: + v = &r.RejectOptions + case C.RuleActionTypePredefined: + v = &r.PredefinedOptions + default: + return E.New("unknown DNS rule action: " + r.Action) + } + if v == nil { + return json.UnmarshalDisallowUnknownFields(data, &_DNSRuleAction{}) + } + return badjson.UnmarshallExcludedContext(ctx, data, (*_DNSRuleAction)(r), v) +} + +type RouteActionOptions struct { + Outbound string `json:"outbound,omitempty"` + RawRouteOptionsActionOptions +} + +type RawRouteOptionsActionOptions struct { + OverrideAddress string `json:"override_address,omitempty"` + OverridePort uint16 `json:"override_port,omitempty"` + + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + FallbackDelay uint32 `json:"fallback_delay,omitempty"` + + UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty"` + UDPConnect bool `json:"udp_connect,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + + TLSFragment bool `json:"tls_fragment,omitempty"` + TLSFragmentFallbackDelay badoption.Duration `json:"tls_fragment_fallback_delay,omitempty"` + TLSRecordFragment bool `json:"tls_record_fragment,omitempty"` +} + +type RouteOptionsActionOptions RawRouteOptionsActionOptions + +func (r *RouteOptionsActionOptions) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*RawRouteOptionsActionOptions)(r)) + if err != nil { + return err + } + if *r == (RouteOptionsActionOptions{}) { + return E.New("empty route option action") + } + if r.TLSFragment && r.TLSRecordFragment { + return E.New("`tls_fragment` and `tls_record_fragment` are mutually exclusive") + } + return nil +} + +type DNSRouteActionOptions struct { + Server string `json:"server,omitempty"` + Strategy DomainStrategy `json:"strategy,omitempty"` + DisableCache bool `json:"disable_cache,omitempty"` + DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` + RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` + ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` +} + +type _DNSRouteOptionsActionOptions struct { + Strategy DomainStrategy `json:"strategy,omitempty"` + DisableCache bool `json:"disable_cache,omitempty"` + DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` + RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` + ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` +} + +type DNSRouteOptionsActionOptions _DNSRouteOptionsActionOptions + +func (r *DNSRouteOptionsActionOptions) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*_DNSRouteOptionsActionOptions)(r)) + if err != nil { + return err + } + if *r == (DNSRouteOptionsActionOptions{}) { + return E.New("empty DNS route option action") + } + return nil +} + +type _DirectActionOptions DialerOptions + +type DirectActionOptions _DirectActionOptions + +func (d DirectActionOptions) Descriptions() []string { + var descriptions []string + if d.BindInterface != "" { + descriptions = append(descriptions, "bind_interface="+d.BindInterface) + } + if d.Inet4BindAddress != nil { + descriptions = append(descriptions, "inet4_bind_address="+d.Inet4BindAddress.Build(netip.IPv4Unspecified()).String()) + } + if d.Inet6BindAddress != nil { + descriptions = append(descriptions, "inet6_bind_address="+d.Inet6BindAddress.Build(netip.IPv6Unspecified()).String()) + } + if d.RoutingMark != 0 { + descriptions = append(descriptions, "routing_mark="+fmt.Sprintf("0x%x", d.RoutingMark)) + } + if d.ReuseAddr { + descriptions = append(descriptions, "reuse_addr") + } + if d.ConnectTimeout != 0 { + descriptions = append(descriptions, "connect_timeout="+time.Duration(d.ConnectTimeout).String()) + } + if d.TCPFastOpen { + descriptions = append(descriptions, "tcp_fast_open") + } + if d.TCPMultiPath { + descriptions = append(descriptions, "tcp_multi_path") + } + if d.UDPFragment != nil { + descriptions = append(descriptions, "udp_fragment="+fmt.Sprint(*d.UDPFragment)) + } + if d.DomainStrategy != DomainStrategy(C.DomainStrategyAsIS) { + descriptions = append(descriptions, "domain_strategy="+d.DomainStrategy.String()) + } + if d.FallbackDelay != 0 { + descriptions = append(descriptions, "fallback_delay="+time.Duration(d.FallbackDelay).String()) + } + return descriptions +} + +func (d *DirectActionOptions) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*_DirectActionOptions)(d)) + if err != nil { + return err + } + if d.Detour != "" { + return E.New("detour is not available in the current context") + } + return nil +} + +type _RejectActionOptions struct { + Method string `json:"method,omitempty"` + NoDrop bool `json:"no_drop,omitempty"` +} + +type RejectActionOptions _RejectActionOptions + +func (r RejectActionOptions) MarshalJSON() ([]byte, error) { + switch r.Method { + case C.RuleActionRejectMethodDefault: + r.Method = "" + } + return json.Marshal((_RejectActionOptions)(r)) +} + +func (r *RejectActionOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_RejectActionOptions)(r)) + if err != nil { + return err + } + switch r.Method { + case "", C.RuleActionRejectMethodDefault: + r.Method = C.RuleActionRejectMethodDefault + case C.RuleActionRejectMethodDrop: + case C.RuleActionRejectMethodReply: + default: + return E.New("unknown reject method: " + r.Method) + } + if r.Method == C.RuleActionRejectMethodDrop && r.NoDrop { + return E.New("no_drop is not available in current context") + } + return nil +} + +type RouteActionSniff struct { + Sniffer badoption.Listable[string] `json:"sniffer,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` +} + +type RouteActionResolve struct { + Server string `json:"server,omitempty"` + Strategy DomainStrategy `json:"strategy,omitempty"` + DisableCache bool `json:"disable_cache,omitempty"` + DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` + RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` + ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` +} + +type DNSRouteActionPredefined struct { + Rcode *DNSRCode `json:"rcode,omitempty"` + Answer badoption.Listable[DNSRecordOptions] `json:"answer,omitempty"` + Ns badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"` + Extra badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"` +} diff --git a/option/rule_action_test.go b/option/rule_action_test.go new file mode 100644 index 00000000..0007cd36 --- /dev/null +++ b/option/rule_action_test.go @@ -0,0 +1,29 @@ +package option + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json" + + "github.com/stretchr/testify/require" +) + +func TestDNSRuleActionRespondUnmarshalJSON(t *testing.T) { + t.Parallel() + + var action DNSRuleAction + err := json.UnmarshalContext(context.Background(), []byte(`{"action":"respond"}`), &action) + require.NoError(t, err) + require.Equal(t, C.RuleActionTypeRespond, action.Action) + require.Equal(t, DNSRouteActionOptions{}, action.RouteOptions) +} + +func TestDNSRuleActionRespondRejectsUnknownFields(t *testing.T) { + t.Parallel() + + var action DNSRuleAction + err := json.UnmarshalContext(context.Background(), []byte(`{"action":"respond","disable_cache":true}`), &action) + require.ErrorContains(t, err, "unknown field") +} diff --git a/option/rule_dns.go b/option/rule_dns.go new file mode 100644 index 00000000..74058a65 --- /dev/null +++ b/option/rule_dns.go @@ -0,0 +1,208 @@ +package option + +import ( + "context" + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type _DNSRule struct { + Type string `json:"type,omitempty"` + DefaultOptions DefaultDNSRule `json:"-"` + LogicalOptions LogicalDNSRule `json:"-"` +} + +type DNSRule _DNSRule + +func (r DNSRule) MarshalJSON() ([]byte, error) { + var v any + switch r.Type { + case C.RuleTypeDefault: + r.Type = "" + v = r.DefaultOptions + case C.RuleTypeLogical: + v = r.LogicalOptions + default: + return nil, E.New("unknown rule type: " + r.Type) + } + return badjson.MarshallObjects((_DNSRule)(r), v) +} + +func (r *DNSRule) UnmarshalJSONContext(ctx context.Context, bytes []byte) error { + err := json.UnmarshalContext(ctx, bytes, (*_DNSRule)(r)) + if err != nil { + return err + } + var v any + switch r.Type { + case "", C.RuleTypeDefault: + r.Type = C.RuleTypeDefault + v = &r.DefaultOptions + case C.RuleTypeLogical: + v = &r.LogicalOptions + default: + return E.New("unknown rule type: " + r.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, bytes, (*_DNSRule)(r), v) + if err != nil { + return err + } + return nil +} + +func (r DNSRule) IsValid() bool { + switch r.Type { + case C.RuleTypeDefault: + return r.DefaultOptions.IsValid() + case C.RuleTypeLogical: + return r.LogicalOptions.IsValid() + default: + panic("unknown DNS rule type: " + r.Type) + } +} + +type RawDefaultDNSRule struct { + Inbound badoption.Listable[string] `json:"inbound,omitempty"` + IPVersion int `json:"ip_version,omitempty"` + QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty"` + AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Domain badoption.Listable[string] `json:"domain,omitempty"` + DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` + DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` + DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` + SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` + SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` + SourcePort badoption.Listable[uint16] `json:"source_port,omitempty"` + SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` + Port badoption.Listable[uint16] `json:"port,omitempty"` + PortRange badoption.Listable[string] `json:"port_range,omitempty"` + ProcessName badoption.Listable[string] `json:"process_name,omitempty"` + ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` + ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` + PackageName badoption.Listable[string] `json:"package_name,omitempty"` + PackageNameRegex badoption.Listable[string] `json:"package_name_regex,omitempty"` + User badoption.Listable[string] `json:"user,omitempty"` + UserID badoption.Listable[int32] `json:"user_id,omitempty"` + Outbound badoption.Listable[string] `json:"outbound,omitempty"` + ClashMode string `json:"clash_mode,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` + NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` + WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` + WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` + InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"` + NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` + DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` + SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` + MatchResponse bool `json:"match_response,omitempty"` + IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` + IPIsPrivate bool `json:"ip_is_private,omitempty"` + IPAcceptAny bool `json:"ip_accept_any,omitempty"` + ResponseRcode *DNSRCode `json:"response_rcode,omitempty"` + ResponseAnswer badoption.Listable[DNSRecordOptions] `json:"response_answer,omitempty"` + ResponseNs badoption.Listable[DNSRecordOptions] `json:"response_ns,omitempty"` + ResponseExtra badoption.Listable[DNSRecordOptions] `json:"response_extra,omitempty"` + Invert bool `json:"invert,omitempty"` + + // Deprecated: removed in sing-box 1.12.0 + Geosite badoption.Listable[string] `json:"geosite,omitempty"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + // Deprecated: removed in sing-box 1.11.0 + RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` + // Deprecated: renamed to rule_set_ip_cidr_match_source + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` +} + +type DefaultDNSRule struct { + RawDefaultDNSRule + DNSRuleAction +} + +func (r DefaultDNSRule) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(r.RawDefaultDNSRule, r.DNSRuleAction) +} + +func (r *DefaultDNSRule) UnmarshalJSONContext(ctx context.Context, data []byte) error { + rawAction, routeOptions, err := inspectDNSRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedDNSRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(ctx, data, &r.RawDefaultDNSRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &r.RawDefaultDNSRule, &r.DNSRuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (DNSRouteActionOptions{}) { + r.DNSRuleAction = DNSRuleAction{} + } + return nil +} + +func (r DefaultDNSRule) IsValid() bool { + var defaultValue DefaultDNSRule + defaultValue.Invert = r.Invert + return !reflect.DeepEqual(r, defaultValue) +} + +type RawLogicalDNSRule struct { + Mode string `json:"mode"` + Rules []DNSRule `json:"rules,omitempty"` + Invert bool `json:"invert,omitempty"` +} + +type LogicalDNSRule struct { + RawLogicalDNSRule + DNSRuleAction +} + +func (r LogicalDNSRule) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(r.RawLogicalDNSRule, r.DNSRuleAction) +} + +func (r *LogicalDNSRule) UnmarshalJSONContext(ctx context.Context, data []byte) error { + rawAction, routeOptions, err := inspectDNSRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedDNSRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(nestedRuleChildContext(ctx), data, &r.RawLogicalDNSRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &r.RawLogicalDNSRule, &r.DNSRuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (DNSRouteActionOptions{}) { + r.DNSRuleAction = DNSRuleAction{} + } + return nil +} + +func (r *LogicalDNSRule) IsValid() bool { + return len(r.Rules) > 0 && common.All(r.Rules, DNSRule.IsValid) +} diff --git a/option/rule_nested.go b/option/rule_nested.go new file mode 100644 index 00000000..17216572 --- /dev/null +++ b/option/rule_nested.go @@ -0,0 +1,133 @@ +package option + +import ( + "context" + "reflect" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type nestedRuleDepthContextKey struct{} + +const ( + RouteRuleActionNestedUnsupportedMessage = "rule action is not supported in nested rules" + DNSRuleActionNestedUnsupportedMessage = "DNS rule action is not supported in nested rules" +) + +var ( + routeRuleActionKeys = jsonFieldNames(reflect.TypeFor[_RuleAction](), reflect.TypeFor[RouteActionOptions]()) + dnsRuleActionKeys = jsonFieldNames(reflect.TypeFor[_DNSRuleAction](), reflect.TypeFor[DNSRouteActionOptions]()) +) + +func nestedRuleChildContext(ctx context.Context) context.Context { + return context.WithValue(ctx, nestedRuleDepthContextKey{}, nestedRuleDepth(ctx)+1) +} + +func rejectNestedRouteRuleAction(ctx context.Context, content []byte) error { + return rejectNestedRuleAction(ctx, content, routeRuleActionKeys, RouteRuleActionNestedUnsupportedMessage) +} + +func rejectNestedDNSRuleAction(ctx context.Context, content []byte) error { + return rejectNestedRuleAction(ctx, content, dnsRuleActionKeys, DNSRuleActionNestedUnsupportedMessage) +} + +func nestedRuleDepth(ctx context.Context) int { + depth, _ := ctx.Value(nestedRuleDepthContextKey{}).(int) + return depth +} + +func rejectNestedRuleAction(ctx context.Context, content []byte, keys []string, message string) error { + if nestedRuleDepth(ctx) == 0 { + return nil + } + hasActionKey, err := hasAnyJSONKey(ctx, content, keys...) + if err != nil { + return err + } + if hasActionKey { + return E.New(message) + } + return nil +} + +func hasAnyJSONKey(ctx context.Context, content []byte, keys ...string) (bool, error) { + var object badjson.JSONObject + err := object.UnmarshalJSONContext(ctx, content) + if err != nil { + return false, err + } + for _, key := range keys { + if object.ContainsKey(key) { + return true, nil + } + } + return false, nil +} + +func inspectRouteRuleAction(ctx context.Context, content []byte) (string, RouteActionOptions, error) { + var rawAction _RuleAction + err := json.UnmarshalContext(ctx, content, &rawAction) + if err != nil { + return "", RouteActionOptions{}, err + } + var routeOptions RouteActionOptions + err = json.UnmarshalContext(ctx, content, &routeOptions) + if err != nil { + return "", RouteActionOptions{}, err + } + return rawAction.Action, routeOptions, nil +} + +func inspectDNSRuleAction(ctx context.Context, content []byte) (string, DNSRouteActionOptions, error) { + var rawAction _DNSRuleAction + err := json.UnmarshalContext(ctx, content, &rawAction) + if err != nil { + return "", DNSRouteActionOptions{}, err + } + var routeOptions DNSRouteActionOptions + err = json.UnmarshalContext(ctx, content, &routeOptions) + if err != nil { + return "", DNSRouteActionOptions{}, err + } + return rawAction.Action, routeOptions, nil +} + +func jsonFieldNames(types ...reflect.Type) []string { + fieldMap := make(map[string]struct{}) + for _, fieldType := range types { + appendJSONFieldNames(fieldMap, fieldType) + } + fieldNames := make([]string, 0, len(fieldMap)) + for fieldName := range fieldMap { + fieldNames = append(fieldNames, fieldName) + } + return fieldNames +} + +func appendJSONFieldNames(fieldMap map[string]struct{}, fieldType reflect.Type) { + for fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + if fieldType.Kind() != reflect.Struct { + return + } + for i := range fieldType.NumField() { + field := fieldType.Field(i) + tagValue := field.Tag.Get("json") + tagName, _, _ := strings.Cut(tagValue, ",") + if tagName == "-" { + continue + } + if field.Anonymous && tagName == "" { + appendJSONFieldNames(fieldMap, field.Type) + continue + } + if tagName == "" { + tagName = field.Name + } + fieldMap[tagName] = struct{}{} + } +} diff --git a/option/rule_nested_test.go b/option/rule_nested_test.go new file mode 100644 index 00000000..3b2ef2e5 --- /dev/null +++ b/option/rule_nested_test.go @@ -0,0 +1,68 @@ +package option + +import ( + "context" + "testing" + + "github.com/sagernet/sing/common/json" + + "github.com/stretchr/testify/require" +) + +func TestRuleRejectsNestedDefaultRuleAction(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "outbound": "direct"} + ] + }`), &rule) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) +} + +func TestRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "foo": "bar"} + ] + }`), &rule) + require.ErrorContains(t, err, "unknown field") + require.NotContains(t, err.Error(), RouteRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleRejectsNestedDefaultRuleAction(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "server": "default"} + ] + }`), &rule) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "foo": "bar"} + ] + }`), &rule) + require.ErrorContains(t, err, "unknown field") + require.NotContains(t, err.Error(), DNSRuleActionNestedUnsupportedMessage) +} diff --git a/option/rule_set.go b/option/rule_set.go new file mode 100644 index 00000000..2ca2529a --- /dev/null +++ b/option/rule_set.go @@ -0,0 +1,288 @@ +package option + +import ( + "net/url" + "path/filepath" + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/domain" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + + "go4.org/netipx" +) + +type _RuleSet struct { + Type string `json:"type,omitempty"` + Tag string `json:"tag"` + Format string `json:"format,omitempty"` + InlineOptions PlainRuleSet `json:"-"` + LocalOptions LocalRuleSet `json:"-"` + RemoteOptions RemoteRuleSet `json:"-"` +} + +type RuleSet _RuleSet + +func (r RuleSet) MarshalJSON() ([]byte, error) { + if r.Type != C.RuleSetTypeInline { + var defaultFormat string + switch r.Type { + case C.RuleSetTypeLocal: + defaultFormat = ruleSetDefaultFormat(r.LocalOptions.Path) + case C.RuleSetTypeRemote: + defaultFormat = ruleSetDefaultFormat(r.RemoteOptions.URL) + } + if r.Format == defaultFormat { + r.Format = "" + } + } + var v any + switch r.Type { + case "", C.RuleSetTypeInline: + r.Type = "" + v = r.InlineOptions + case C.RuleSetTypeLocal: + v = r.LocalOptions + case C.RuleSetTypeRemote: + v = r.RemoteOptions + default: + return nil, E.New("unknown rule-set type: " + r.Type) + } + return badjson.MarshallObjects((_RuleSet)(r), v) +} + +func (r *RuleSet) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_RuleSet)(r)) + if err != nil { + return err + } + if r.Tag == "" { + return E.New("missing tag") + } + var v any + switch r.Type { + case "", C.RuleSetTypeInline: + r.Type = C.RuleSetTypeInline + v = &r.InlineOptions + case C.RuleSetTypeLocal: + v = &r.LocalOptions + case C.RuleSetTypeRemote: + v = &r.RemoteOptions + default: + return E.New("unknown rule-set type: " + r.Type) + } + err = badjson.UnmarshallExcluded(bytes, (*_RuleSet)(r), v) + if err != nil { + return err + } + if r.Type != C.RuleSetTypeInline { + if r.Format == "" { + switch r.Type { + case C.RuleSetTypeLocal: + r.Format = ruleSetDefaultFormat(r.LocalOptions.Path) + case C.RuleSetTypeRemote: + r.Format = ruleSetDefaultFormat(r.RemoteOptions.URL) + } + } + switch r.Format { + case "": + return E.New("missing format") + case C.RuleSetFormatSource, C.RuleSetFormatBinary: + default: + return E.New("unknown rule-set format: " + r.Format) + } + } else { + r.Format = "" + } + return nil +} + +func ruleSetDefaultFormat(path string) string { + if pathURL, err := url.Parse(path); err == nil { + path = pathURL.Path + } + switch filepath.Ext(path) { + case ".json": + return C.RuleSetFormatSource + case ".srs": + return C.RuleSetFormatBinary + default: + return "" + } +} + +type LocalRuleSet struct { + Path string `json:"path,omitempty"` +} + +type RemoteRuleSet struct { + URL string `json:"url"` + DownloadDetour string `json:"download_detour,omitempty"` + UpdateInterval badoption.Duration `json:"update_interval,omitempty"` +} + +type _HeadlessRule struct { + Type string `json:"type,omitempty"` + DefaultOptions DefaultHeadlessRule `json:"-"` + LogicalOptions LogicalHeadlessRule `json:"-"` +} + +type HeadlessRule _HeadlessRule + +func (r HeadlessRule) MarshalJSON() ([]byte, error) { + var v any + switch r.Type { + case C.RuleTypeDefault: + r.Type = "" + v = r.DefaultOptions + case C.RuleTypeLogical: + v = r.LogicalOptions + default: + return nil, E.New("unknown rule type: " + r.Type) + } + return badjson.MarshallObjects((_HeadlessRule)(r), v) +} + +func (r *HeadlessRule) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_HeadlessRule)(r)) + if err != nil { + return err + } + var v any + switch r.Type { + case "", C.RuleTypeDefault: + r.Type = C.RuleTypeDefault + v = &r.DefaultOptions + case C.RuleTypeLogical: + v = &r.LogicalOptions + default: + return E.New("unknown rule type: " + r.Type) + } + err = badjson.UnmarshallExcluded(bytes, (*_HeadlessRule)(r), v) + if err != nil { + return err + } + return nil +} + +func (r HeadlessRule) IsValid() bool { + switch r.Type { + case C.RuleTypeDefault, "": + return r.DefaultOptions.IsValid() + case C.RuleTypeLogical: + return r.LogicalOptions.IsValid() + default: + panic("unknown rule type: " + r.Type) + } +} + +type DefaultHeadlessRule struct { + QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty"` + Domain badoption.Listable[string] `json:"domain,omitempty"` + DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` + DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` + DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` + SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` + IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` + SourcePort badoption.Listable[uint16] `json:"source_port,omitempty"` + SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` + Port badoption.Listable[uint16] `json:"port,omitempty"` + PortRange badoption.Listable[string] `json:"port_range,omitempty"` + ProcessName badoption.Listable[string] `json:"process_name,omitempty"` + ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` + ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` + PackageName badoption.Listable[string] `json:"package_name,omitempty"` + PackageNameRegex badoption.Listable[string] `json:"package_name_regex,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` + NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` + WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` + WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` + NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` + DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + + Invert bool `json:"invert,omitempty"` + + DomainMatcher *domain.Matcher `json:"-"` + SourceIPSet *netipx.IPSet `json:"-"` + IPSet *netipx.IPSet `json:"-"` + + AdGuardDomain badoption.Listable[string] `json:"-"` + AdGuardDomainMatcher *domain.AdGuardMatcher `json:"-"` +} + +func (r DefaultHeadlessRule) IsValid() bool { + var defaultValue DefaultHeadlessRule + defaultValue.Invert = r.Invert + return !reflect.DeepEqual(r, defaultValue) +} + +type LogicalHeadlessRule struct { + Mode string `json:"mode"` + Rules []HeadlessRule `json:"rules,omitempty"` + Invert bool `json:"invert,omitempty"` +} + +func (r LogicalHeadlessRule) IsValid() bool { + return len(r.Rules) > 0 && common.All(r.Rules, HeadlessRule.IsValid) +} + +type _PlainRuleSetCompat struct { + Version uint8 `json:"version"` + Options PlainRuleSet `json:"-"` + RawMessage json.RawMessage `json:"-"` +} + +type PlainRuleSetCompat _PlainRuleSetCompat + +func (r PlainRuleSetCompat) MarshalJSON() ([]byte, error) { + var v any + switch r.Version { + case C.RuleSetVersion1, C.RuleSetVersion2, C.RuleSetVersion3, C.RuleSetVersion4, C.RuleSetVersion5: + v = r.Options + default: + return nil, E.New("unknown rule-set version: ", r.Version) + } + return badjson.MarshallObjects((_PlainRuleSetCompat)(r), v) +} + +func (r *PlainRuleSetCompat) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_PlainRuleSetCompat)(r)) + if err != nil { + return err + } + var v any + switch r.Version { + case C.RuleSetVersion1, C.RuleSetVersion2, C.RuleSetVersion3, C.RuleSetVersion4, C.RuleSetVersion5: + v = &r.Options + case 0: + return E.New("missing rule-set version") + default: + return E.New("unknown rule-set version: ", r.Version) + } + err = badjson.UnmarshallExcluded(bytes, (*_PlainRuleSetCompat)(r), v) + if err != nil { + return err + } + r.RawMessage = bytes + return nil +} + +func (r PlainRuleSetCompat) Upgrade() (PlainRuleSet, error) { + switch r.Version { + case C.RuleSetVersion1, C.RuleSetVersion2, C.RuleSetVersion3, C.RuleSetVersion4, C.RuleSetVersion5: + default: + return PlainRuleSet{}, E.New("unknown rule-set version: " + F.ToString(r.Version)) + } + return r.Options, nil +} + +type PlainRuleSet struct { + Rules []HeadlessRule `json:"rules,omitempty"` +} diff --git a/option/service.go b/option/service.go new file mode 100644 index 00000000..7d45bc14 --- /dev/null +++ b/option/service.go @@ -0,0 +1,47 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/service" +) + +type ServiceOptionsRegistry interface { + CreateOptions(serviceType string) (any, bool) +} + +type _Service struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Service _Service + +func (h *Service) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Service)(h), h.Options) +} + +func (h *Service) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Service)(h)) + if err != nil { + return err + } + registry := service.FromContext[ServiceOptionsRegistry](ctx) + if registry == nil { + return E.New("missing service fields registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown inbound type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Service)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} diff --git a/option/shadowsocks.go b/option/shadowsocks.go new file mode 100644 index 00000000..7cb656f3 --- /dev/null +++ b/option/shadowsocks.go @@ -0,0 +1,35 @@ +package option + +type ShadowsocksInboundOptions struct { + ListenOptions + Network NetworkList `json:"network,omitempty"` + Method string `json:"method"` + Password string `json:"password,omitempty"` + Users []ShadowsocksUser `json:"users,omitempty"` + Destinations []ShadowsocksDestination `json:"destinations,omitempty"` + Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` + Managed bool `json:"managed,omitempty"` +} + +type ShadowsocksUser struct { + Name string `json:"name"` + Password string `json:"password"` +} + +type ShadowsocksDestination struct { + Name string `json:"name"` + Password string `json:"password"` + ServerOptions +} + +type ShadowsocksOutboundOptions struct { + DialerOptions + ServerOptions + Method string `json:"method"` + Password string `json:"password"` + Plugin string `json:"plugin,omitempty"` + PluginOptions string `json:"plugin_opts,omitempty"` + Network NetworkList `json:"network,omitempty"` + UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` + Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` +} diff --git a/option/shadowsocksr.go b/option/shadowsocksr.go new file mode 100644 index 00000000..b87255bb --- /dev/null +++ b/option/shadowsocksr.go @@ -0,0 +1,13 @@ +package option + +type ShadowsocksROutboundOptions struct { + DialerOptions + ServerOptions + Method string `json:"method"` + Password string `json:"password"` + Obfs string `json:"obfs,omitempty"` + ObfsParam string `json:"obfs_param,omitempty"` + Protocol string `json:"protocol,omitempty"` + ProtocolParam string `json:"protocol_param,omitempty"` + Network NetworkList `json:"network,omitempty"` +} diff --git a/option/shadowtls.go b/option/shadowtls.go new file mode 100644 index 00000000..81ef9a43 --- /dev/null +++ b/option/shadowtls.go @@ -0,0 +1,81 @@ +package option + +import ( + "encoding/json" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badjson" +) + +type ShadowTLSInboundOptions struct { + ListenOptions + Version int `json:"version,omitempty"` + Password string `json:"password,omitempty"` + Users []ShadowTLSUser `json:"users,omitempty"` + Handshake ShadowTLSHandshakeOptions `json:"handshake,omitempty"` + HandshakeForServerName *badjson.TypedMap[string, ShadowTLSHandshakeOptions] `json:"handshake_for_server_name,omitempty"` + StrictMode bool `json:"strict_mode,omitempty"` + WildcardSNI WildcardSNI `json:"wildcard_sni,omitempty"` +} + +type WildcardSNI int + +const ( + ShadowTLSWildcardSNIOff WildcardSNI = iota + ShadowTLSWildcardSNIAuthed + ShadowTLSWildcardSNIAll +) + +func (w WildcardSNI) MarshalJSON() ([]byte, error) { + return json.Marshal(w.String()) +} + +func (w WildcardSNI) String() string { + switch w { + case ShadowTLSWildcardSNIOff: + return "off" + case ShadowTLSWildcardSNIAuthed: + return "authed" + case ShadowTLSWildcardSNIAll: + return "all" + default: + panic("unknown wildcard SNI value") + } +} + +func (w *WildcardSNI) UnmarshalJSON(bytes []byte) error { + var valueString string + err := json.Unmarshal(bytes, &valueString) + if err != nil { + return err + } + switch valueString { + case "off", "": + *w = ShadowTLSWildcardSNIOff + case "authed": + *w = ShadowTLSWildcardSNIAuthed + case "all": + *w = ShadowTLSWildcardSNIAll + default: + return E.New("unknown wildcard SNI value: ", valueString) + } + return nil +} + +type ShadowTLSUser struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` +} + +type ShadowTLSHandshakeOptions struct { + ServerOptions + DialerOptions +} + +type ShadowTLSOutboundOptions struct { + DialerOptions + ServerOptions + Version int `json:"version,omitempty"` + Password string `json:"password,omitempty"` + OutboundTLSOptionsContainer +} diff --git a/option/simple.go b/option/simple.go new file mode 100644 index 00000000..f244ba18 --- /dev/null +++ b/option/simple.go @@ -0,0 +1,40 @@ +package option + +import ( + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" +) + +type SocksInboundOptions struct { + ListenOptions + Users []auth.User `json:"users,omitempty"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` +} + +type HTTPMixedInboundOptions struct { + ListenOptions + Users []auth.User `json:"users,omitempty"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + SetSystemProxy bool `json:"set_system_proxy,omitempty"` + InboundTLSOptionsContainer +} + +type SOCKSOutboundOptions struct { + DialerOptions + ServerOptions + Version string `json:"version,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Network NetworkList `json:"network,omitempty"` + UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` +} + +type HTTPOutboundOptions struct { + DialerOptions + ServerOptions + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + OutboundTLSOptionsContainer + Path string `json:"path,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` +} diff --git a/option/ssh.go b/option/ssh.go new file mode 100644 index 00000000..1c6ca6bb --- /dev/null +++ b/option/ssh.go @@ -0,0 +1,16 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type SSHOutboundOptions struct { + DialerOptions + ServerOptions + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + PrivateKey badoption.Listable[string] `json:"private_key,omitempty"` + PrivateKeyPath string `json:"private_key_path,omitempty"` + PrivateKeyPassphrase string `json:"private_key_passphrase,omitempty"` + HostKey badoption.Listable[string] `json:"host_key,omitempty"` + HostKeyAlgorithms badoption.Listable[string] `json:"host_key_algorithms,omitempty"` + ClientVersion string `json:"client_version,omitempty"` +} diff --git a/option/ssmapi.go b/option/ssmapi.go new file mode 100644 index 00000000..8d25f400 --- /dev/null +++ b/option/ssmapi.go @@ -0,0 +1,12 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badjson" +) + +type SSMAPIServiceOptions struct { + ListenOptions + Servers *badjson.TypedMap[string, string] `json:"servers"` + CachePath string `json:"cache_path,omitempty"` + InboundTLSOptionsContainer +} diff --git a/option/tailscale.go b/option/tailscale.go new file mode 100644 index 00000000..a4f82ce0 --- /dev/null +++ b/option/tailscale.go @@ -0,0 +1,126 @@ +package option + +import ( + "net/netip" + "net/url" + "reflect" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" +) + +type TailscaleEndpointOptions struct { + DialerOptions + StateDirectory string `json:"state_directory,omitempty"` + AuthKey string `json:"auth_key,omitempty"` + ControlURL string `json:"control_url,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` + Hostname string `json:"hostname,omitempty"` + AcceptRoutes bool `json:"accept_routes,omitempty"` + ExitNode string `json:"exit_node,omitempty"` + ExitNodeAllowLANAccess bool `json:"exit_node_allow_lan_access,omitempty"` + AdvertiseRoutes []netip.Prefix `json:"advertise_routes,omitempty"` + AdvertiseExitNode bool `json:"advertise_exit_node,omitempty"` + AdvertiseTags badoption.Listable[string] `json:"advertise_tags,omitempty"` + RelayServerPort *uint16 `json:"relay_server_port,omitempty"` + RelayServerStaticEndpoints []netip.AddrPort `json:"relay_server_static_endpoints,omitempty"` + SystemInterface bool `json:"system_interface,omitempty"` + SystemInterfaceName string `json:"system_interface_name,omitempty"` + SystemInterfaceMTU uint32 `json:"system_interface_mtu,omitempty"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` +} + +type TailscaleDNSServerOptions struct { + Endpoint string `json:"endpoint,omitempty"` + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` +} + +type TailscaleCertificateProviderOptions struct { + Endpoint string `json:"endpoint,omitempty"` +} + +type DERPServiceOptions struct { + ListenOptions + InboundTLSOptionsContainer + ConfigPath string `json:"config_path,omitempty"` + VerifyClientEndpoint badoption.Listable[string] `json:"verify_client_endpoint,omitempty"` + VerifyClientURL badoption.Listable[*DERPVerifyClientURLOptions] `json:"verify_client_url,omitempty"` + Home string `json:"home,omitempty"` + MeshWith badoption.Listable[*DERPMeshOptions] `json:"mesh_with,omitempty"` + MeshPSK string `json:"mesh_psk,omitempty"` + MeshPSKFile string `json:"mesh_psk_file,omitempty"` + STUN *DERPSTUNListenOptions `json:"stun,omitempty"` +} + +type _DERPVerifyClientURLOptions struct { + URL string `json:"url,omitempty"` + DialerOptions +} + +type DERPVerifyClientURLOptions _DERPVerifyClientURLOptions + +func (d DERPVerifyClientURLOptions) ServerIsDomain() bool { + verifyURL, err := url.Parse(d.URL) + if err != nil { + return false + } + return M.ParseSocksaddr(verifyURL.Hostname()).IsDomain() +} + +func (d DERPVerifyClientURLOptions) MarshalJSON() ([]byte, error) { + if reflect.DeepEqual(d, _DERPVerifyClientURLOptions{}) { + return json.Marshal(d.URL) + } else { + return json.Marshal(_DERPVerifyClientURLOptions(d)) + } +} + +func (d *DERPVerifyClientURLOptions) UnmarshalJSON(bytes []byte) error { + var stringValue string + err := json.Unmarshal(bytes, &stringValue) + if err == nil { + d.URL = stringValue + return nil + } + return json.Unmarshal(bytes, (*_DERPVerifyClientURLOptions)(d)) +} + +type DERPMeshOptions struct { + ServerOptions + Host string `json:"host,omitempty"` + OutboundTLSOptionsContainer + DialerOptions +} + +type _DERPSTUNListenOptions struct { + Enabled bool + ListenOptions +} + +type DERPSTUNListenOptions _DERPSTUNListenOptions + +func (d DERPSTUNListenOptions) MarshalJSON() ([]byte, error) { + portOptions := _DERPSTUNListenOptions{ + Enabled: d.Enabled, + ListenOptions: ListenOptions{ + ListenPort: d.ListenPort, + }, + } + if _DERPSTUNListenOptions(d) == portOptions { + return json.Marshal(d.Enabled) + } else { + return json.Marshal(_DERPSTUNListenOptions(d)) + } +} + +func (d *DERPSTUNListenOptions) UnmarshalJSON(bytes []byte) error { + var portValue uint16 + err := json.Unmarshal(bytes, &portValue) + if err == nil { + d.Enabled = true + d.ListenPort = portValue + return nil + } + return json.Unmarshal(bytes, (*_DERPSTUNListenOptions)(d)) +} diff --git a/option/tls.go b/option/tls.go new file mode 100644 index 00000000..dbbb7620 --- /dev/null +++ b/option/tls.go @@ -0,0 +1,242 @@ +package option + +import ( + "crypto/tls" + "encoding/json" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +type InboundTLSOptions struct { + Enabled bool `json:"enabled,omitempty"` + ServerName string `json:"server_name,omitempty"` + Insecure bool `json:"insecure,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty"` + MinVersion string `json:"min_version,omitempty"` + MaxVersion string `json:"max_version,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + ClientAuthentication ClientAuthType `json:"client_authentication,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath badoption.Listable[string] `json:"client_certificate_path,omitempty"` + ClientCertificatePublicKeySHA256 badoption.Listable[[]byte] `json:"client_certificate_public_key_sha256,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` + CertificateProvider *CertificateProviderOptions `json:"certificate_provider,omitempty"` + + // Deprecated: use certificate_provider + ACME *InboundACMEOptions `json:"acme,omitempty"` + + ECH *InboundECHOptions `json:"ech,omitempty"` + Reality *InboundRealityOptions `json:"reality,omitempty"` +} + +type ClientAuthType tls.ClientAuthType + +func (t ClientAuthType) MarshalJSON() ([]byte, error) { + var stringValue string + switch t { + case ClientAuthType(tls.NoClientCert): + stringValue = "no" + case ClientAuthType(tls.RequestClientCert): + stringValue = "request" + case ClientAuthType(tls.RequireAnyClientCert): + stringValue = "require-any" + case ClientAuthType(tls.VerifyClientCertIfGiven): + stringValue = "verify-if-given" + case ClientAuthType(tls.RequireAndVerifyClientCert): + stringValue = "require-and-verify" + default: + return nil, E.New("unknown client authentication type: ", int(t)) + } + return json.Marshal(stringValue) +} + +func (t *ClientAuthType) UnmarshalJSON(data []byte) error { + var stringValue string + err := json.Unmarshal(data, &stringValue) + if err != nil { + return err + } + switch stringValue { + case "no": + *t = ClientAuthType(tls.NoClientCert) + case "request": + *t = ClientAuthType(tls.RequestClientCert) + case "require-any": + *t = ClientAuthType(tls.RequireAnyClientCert) + case "verify-if-given": + *t = ClientAuthType(tls.VerifyClientCertIfGiven) + case "require-and-verify": + *t = ClientAuthType(tls.RequireAndVerifyClientCert) + default: + return E.New("unknown client authentication type: ", stringValue) + } + return nil +} + +type InboundTLSOptionsContainer struct { + TLS *InboundTLSOptions `json:"tls,omitempty"` +} + +type InboundTLSOptionsWrapper interface { + TakeInboundTLSOptions() *InboundTLSOptions + ReplaceInboundTLSOptions(options *InboundTLSOptions) +} + +func (o *InboundTLSOptionsContainer) TakeInboundTLSOptions() *InboundTLSOptions { + return o.TLS +} + +func (o *InboundTLSOptionsContainer) ReplaceInboundTLSOptions(options *InboundTLSOptions) { + o.TLS = options +} + +type OutboundTLSOptions struct { + Enabled bool `json:"enabled,omitempty"` + DisableSNI bool `json:"disable_sni,omitempty"` + ServerName string `json:"server_name,omitempty"` + Insecure bool `json:"insecure,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty"` + MinVersion string `json:"min_version,omitempty"` + MaxVersion string `json:"max_version,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + CertificatePublicKeySHA256 badoption.Listable[[]byte] `json:"certificate_public_key_sha256,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + ClientKey badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath string `json:"client_key_path,omitempty"` + Fragment bool `json:"fragment,omitempty"` + FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment bool `json:"record_fragment,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` + ECH *OutboundECHOptions `json:"ech,omitempty"` + UTLS *OutboundUTLSOptions `json:"utls,omitempty"` + Reality *OutboundRealityOptions `json:"reality,omitempty"` +} + +type OutboundTLSOptionsContainer struct { + TLS *OutboundTLSOptions `json:"tls,omitempty"` +} + +type OutboundTLSOptionsWrapper interface { + TakeOutboundTLSOptions() *OutboundTLSOptions + ReplaceOutboundTLSOptions(options *OutboundTLSOptions) +} + +func (o *OutboundTLSOptionsContainer) TakeOutboundTLSOptions() *OutboundTLSOptions { + return o.TLS +} + +func (o *OutboundTLSOptionsContainer) ReplaceOutboundTLSOptions(options *OutboundTLSOptions) { + o.TLS = options +} + +type CurvePreference tls.CurveID + +const ( + CurveP256 = 23 + CurveP384 = 24 + CurveP521 = 25 + X25519 = 29 + X25519MLKEM768 = 4588 +) + +func (c CurvePreference) MarshalJSON() ([]byte, error) { + var stringValue string + switch c { + case CurvePreference(CurveP256): + stringValue = "P256" + case CurvePreference(CurveP384): + stringValue = "P384" + case CurvePreference(CurveP521): + stringValue = "P521" + case CurvePreference(X25519): + stringValue = "X25519" + case CurvePreference(X25519MLKEM768): + stringValue = "X25519MLKEM768" + default: + return nil, E.New("unknown curve id: ", int(c)) + } + return json.Marshal(stringValue) +} + +func (c *CurvePreference) UnmarshalJSON(data []byte) error { + var stringValue string + err := json.Unmarshal(data, &stringValue) + if err != nil { + return err + } + switch strings.ToUpper(stringValue) { + case "P256": + *c = CurvePreference(CurveP256) + case "P384": + *c = CurvePreference(CurveP384) + case "P521": + *c = CurvePreference(CurveP521) + case "X25519": + *c = CurvePreference(X25519) + case "X25519MLKEM768": + *c = CurvePreference(X25519MLKEM768) + default: + return E.New("unknown curve name: ", stringValue) + } + return nil +} + +type InboundRealityOptions struct { + Enabled bool `json:"enabled,omitempty"` + Handshake InboundRealityHandshakeOptions `json:"handshake,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + ShortID badoption.Listable[string] `json:"short_id,omitempty"` + MaxTimeDifference badoption.Duration `json:"max_time_difference,omitempty"` +} + +type InboundRealityHandshakeOptions struct { + ServerOptions + DialerOptions +} + +type InboundECHOptions struct { + Enabled bool `json:"enabled,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + + // Deprecated: not supported by stdlib + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + // Deprecated: added by fault + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` +} + +type OutboundECHOptions struct { + Enabled bool `json:"enabled,omitempty"` + Config badoption.Listable[string] `json:"config,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + QueryServerName string `json:"query_server_name,omitempty"` + + // Deprecated: not supported by stdlib + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + // Deprecated: added by fault + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` +} + +type OutboundUTLSOptions struct { + Enabled bool `json:"enabled,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type OutboundRealityOptions struct { + Enabled bool `json:"enabled,omitempty"` + PublicKey string `json:"public_key,omitempty"` + ShortID string `json:"short_id,omitempty"` +} diff --git a/option/tls_acme.go b/option/tls_acme.go new file mode 100644 index 00000000..6dd8fa70 --- /dev/null +++ b/option/tls_acme.go @@ -0,0 +1,96 @@ +package option + +import ( + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type InboundACMEOptions struct { + Domain badoption.Listable[string] `json:"domain,omitempty"` + DataDirectory string `json:"data_directory,omitempty"` + DefaultServerName string `json:"default_server_name,omitempty"` + Email string `json:"email,omitempty"` + Provider string `json:"provider,omitempty"` + DisableHTTPChallenge bool `json:"disable_http_challenge,omitempty"` + DisableTLSALPNChallenge bool `json:"disable_tls_alpn_challenge,omitempty"` + AlternativeHTTPPort uint16 `json:"alternative_http_port,omitempty"` + AlternativeTLSPort uint16 `json:"alternative_tls_port,omitempty"` + ExternalAccount *ACMEExternalAccountOptions `json:"external_account,omitempty"` + DNS01Challenge *ACMEDNS01ChallengeOptions `json:"dns01_challenge,omitempty"` +} + +type ACMEExternalAccountOptions struct { + KeyID string `json:"key_id,omitempty"` + MACKey string `json:"mac_key,omitempty"` +} + +type _ACMEDNS01ChallengeOptions struct { + Provider string `json:"provider,omitempty"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` +} + +type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions + +func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { + var v any + switch o.Provider { + case C.DNSProviderAliDNS: + v = o.AliDNSOptions + case C.DNSProviderCloudflare: + v = o.CloudflareOptions + case C.DNSProviderACMEDNS: + v = o.ACMEDNSOptions + case "": + return nil, E.New("missing provider type") + default: + return nil, E.New("unknown provider type: " + o.Provider) + } + return badjson.MarshallObjects((_ACMEDNS01ChallengeOptions)(o), v) +} + +func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_ACMEDNS01ChallengeOptions)(o)) + if err != nil { + return err + } + var v any + switch o.Provider { + case C.DNSProviderAliDNS: + v = &o.AliDNSOptions + case C.DNSProviderCloudflare: + v = &o.CloudflareOptions + case C.DNSProviderACMEDNS: + v = &o.ACMEDNSOptions + default: + return E.New("unknown provider type: " + o.Provider) + } + err = badjson.UnmarshallExcluded(bytes, (*_ACMEDNS01ChallengeOptions)(o), v) + if err != nil { + return err + } + return nil +} + +type ACMEDNS01AliDNSOptions struct { + AccessKeyID string `json:"access_key_id,omitempty"` + AccessKeySecret string `json:"access_key_secret,omitempty"` + RegionID string `json:"region_id,omitempty"` + SecurityToken string `json:"security_token,omitempty"` +} + +type ACMEDNS01CloudflareOptions struct { + APIToken string `json:"api_token,omitempty"` + ZoneToken string `json:"zone_token,omitempty"` +} + +type ACMEDNS01ACMEDNSOptions struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Subdomain string `json:"subdomain,omitempty"` + ServerURL string `json:"server_url,omitempty"` +} diff --git a/option/tor.go b/option/tor.go new file mode 100644 index 00000000..a56f70ae --- /dev/null +++ b/option/tor.go @@ -0,0 +1,9 @@ +package option + +type TorOutboundOptions struct { + DialerOptions + ExecutablePath string `json:"executable_path,omitempty"` + ExtraArgs []string `json:"extra_args,omitempty"` + DataDirectory string `json:"data_directory,omitempty"` + Options map[string]string `json:"torrc,omitempty"` +} diff --git a/option/trojan.go b/option/trojan.go new file mode 100644 index 00000000..7d36c8d2 --- /dev/null +++ b/option/trojan.go @@ -0,0 +1,26 @@ +package option + +type TrojanInboundOptions struct { + ListenOptions + Users []TrojanUser `json:"users,omitempty"` + InboundTLSOptionsContainer + Fallback *ServerOptions `json:"fallback,omitempty"` + FallbackForALPN map[string]*ServerOptions `json:"fallback_for_alpn,omitempty"` + Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` + Transport *V2RayTransportOptions `json:"transport,omitempty"` +} + +type TrojanUser struct { + Name string `json:"name"` + Password string `json:"password"` +} + +type TrojanOutboundOptions struct { + DialerOptions + ServerOptions + Password string `json:"password"` + Network NetworkList `json:"network,omitempty"` + OutboundTLSOptionsContainer + Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` + Transport *V2RayTransportOptions `json:"transport,omitempty"` +} diff --git a/option/tuic.go b/option/tuic.go new file mode 100644 index 00000000..a9b739ec --- /dev/null +++ b/option/tuic.go @@ -0,0 +1,33 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type TUICInboundOptions struct { + ListenOptions + Users []TUICUser `json:"users,omitempty"` + CongestionControl string `json:"congestion_control,omitempty"` + AuthTimeout badoption.Duration `json:"auth_timeout,omitempty"` + ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"` + Heartbeat badoption.Duration `json:"heartbeat,omitempty"` + InboundTLSOptionsContainer +} + +type TUICUser struct { + Name string `json:"name,omitempty"` + UUID string `json:"uuid,omitempty"` + Password string `json:"password,omitempty"` +} + +type TUICOutboundOptions struct { + DialerOptions + ServerOptions + UUID string `json:"uuid,omitempty"` + Password string `json:"password,omitempty"` + CongestionControl string `json:"congestion_control,omitempty"` + UDPRelayMode string `json:"udp_relay_mode,omitempty"` + UDPOverStream bool `json:"udp_over_stream,omitempty"` + ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"` + Heartbeat badoption.Duration `json:"heartbeat,omitempty"` + Network NetworkList `json:"network,omitempty"` + OutboundTLSOptionsContainer +} diff --git a/option/tun.go b/option/tun.go new file mode 100644 index 00000000..fda028b6 --- /dev/null +++ b/option/tun.go @@ -0,0 +1,88 @@ +package option + +import ( + "net/netip" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +type TunInboundOptions struct { + InterfaceName string `json:"interface_name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` + AutoRoute bool `json:"auto_route,omitempty"` + IPRoute2TableIndex int `json:"iproute2_table_index,omitempty"` + IPRoute2RuleIndex int `json:"iproute2_rule_index,omitempty"` + AutoRedirect bool `json:"auto_redirect,omitempty"` + AutoRedirectInputMark FwMark `json:"auto_redirect_input_mark,omitempty"` + AutoRedirectOutputMark FwMark `json:"auto_redirect_output_mark,omitempty"` + AutoRedirectResetMark FwMark `json:"auto_redirect_reset_mark,omitempty"` + AutoRedirectNFQueue uint16 `json:"auto_redirect_nfqueue,omitempty"` + AutoRedirectFallbackRuleIndex int `json:"auto_redirect_iproute2_fallback_rule_index,omitempty"` + ExcludeMPTCP bool `json:"exclude_mptcp,omitempty"` + LoopbackAddress badoption.Listable[netip.Addr] `json:"loopback_address,omitempty"` + StrictRoute bool `json:"strict_route,omitempty"` + RouteAddress badoption.Listable[netip.Prefix] `json:"route_address,omitempty"` + RouteAddressSet badoption.Listable[string] `json:"route_address_set,omitempty"` + RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"route_exclude_address,omitempty"` + RouteExcludeAddressSet badoption.Listable[string] `json:"route_exclude_address_set,omitempty"` + IncludeInterface badoption.Listable[string] `json:"include_interface,omitempty"` + ExcludeInterface badoption.Listable[string] `json:"exclude_interface,omitempty"` + IncludeUID badoption.Listable[uint32] `json:"include_uid,omitempty"` + IncludeUIDRange badoption.Listable[string] `json:"include_uid_range,omitempty"` + ExcludeUID badoption.Listable[uint32] `json:"exclude_uid,omitempty"` + ExcludeUIDRange badoption.Listable[string] `json:"exclude_uid_range,omitempty"` + IncludeAndroidUser badoption.Listable[int] `json:"include_android_user,omitempty"` + IncludePackage badoption.Listable[string] `json:"include_package,omitempty"` + ExcludePackage badoption.Listable[string] `json:"exclude_package,omitempty"` + IncludeMACAddress badoption.Listable[string] `json:"include_mac_address,omitempty"` + ExcludeMACAddress badoption.Listable[string] `json:"exclude_mac_address,omitempty"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` + Stack string `json:"stack,omitempty"` + Platform *TunPlatformOptions `json:"platform,omitempty"` + InboundOptions + + // Deprecated: removed + GSO bool `json:"gso,omitempty"` + // Deprecated: merged to Address + Inet4Address badoption.Listable[netip.Prefix] `json:"inet4_address,omitempty"` + // Deprecated: merged to Address + Inet6Address badoption.Listable[netip.Prefix] `json:"inet6_address,omitempty"` + // Deprecated: merged to RouteAddress + Inet4RouteAddress badoption.Listable[netip.Prefix] `json:"inet4_route_address,omitempty"` + // Deprecated: merged to RouteAddress + Inet6RouteAddress badoption.Listable[netip.Prefix] `json:"inet6_route_address,omitempty"` + // Deprecated: merged to RouteExcludeAddress + Inet4RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty"` + // Deprecated: merged to RouteExcludeAddress + Inet6RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty"` + // Deprecated: removed + EndpointIndependentNat bool `json:"endpoint_independent_nat,omitempty"` +} + +type FwMark uint32 + +func (f FwMark) MarshalJSON() ([]byte, error) { + return json.Marshal(F.ToString("0x", strconv.FormatUint(uint64(f), 16))) +} + +func (f *FwMark) UnmarshalJSON(bytes []byte) error { + var stringValue string + err := json.Unmarshal(bytes, &stringValue) + if err != nil { + if rawErr := json.Unmarshal(bytes, (*uint32)(f)); rawErr == nil { + return nil + } + return E.Cause(err, "invalid number or string mark") + } + intValue, err := strconv.ParseUint(stringValue, 0, 32) + if err != nil { + return err + } + *f = FwMark(intValue) + return nil +} diff --git a/option/tun_platform.go b/option/tun_platform.go new file mode 100644 index 00000000..b42f6894 --- /dev/null +++ b/option/tun_platform.go @@ -0,0 +1,14 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type TunPlatformOptions struct { + HTTPProxy *HTTPProxyOptions `json:"http_proxy,omitempty"` +} + +type HTTPProxyOptions struct { + Enabled bool `json:"enabled,omitempty"` + ServerOptions + BypassDomain badoption.Listable[string] `json:"bypass_domain,omitempty"` + MatchDomain badoption.Listable[string] `json:"match_domain,omitempty"` +} diff --git a/option/types.go b/option/types.go new file mode 100644 index 00000000..fe7d4b3d --- /dev/null +++ b/option/types.go @@ -0,0 +1,196 @@ +package option + +import ( + "strings" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" +) + +type NetworkList string + +func (v *NetworkList) UnmarshalJSON(content []byte) error { + var networkList []string + err := json.Unmarshal(content, &networkList) + if err != nil { + var networkItem string + err = json.Unmarshal(content, &networkItem) + if err != nil { + return err + } + networkList = []string{networkItem} + } + for _, networkName := range networkList { + switch networkName { + case N.NetworkTCP, N.NetworkUDP: + break + default: + return E.New("unknown network: " + networkName) + } + } + *v = NetworkList(strings.Join(networkList, "\n")) + return nil +} + +func (v NetworkList) Build() []string { + if v == "" { + return []string{N.NetworkTCP, N.NetworkUDP} + } + return strings.Split(string(v), "\n") +} + +type DomainStrategy C.DomainStrategy + +func (s DomainStrategy) String() string { + switch C.DomainStrategy(s) { + case C.DomainStrategyAsIS: + return "" + case C.DomainStrategyPreferIPv4: + return "prefer_ipv4" + case C.DomainStrategyPreferIPv6: + return "prefer_ipv6" + case C.DomainStrategyIPv4Only: + return "ipv4_only" + case C.DomainStrategyIPv6Only: + return "ipv6_only" + default: + panic(E.New("unknown domain strategy: ", s)) + } +} + +func (s DomainStrategy) MarshalJSON() ([]byte, error) { + var value string + switch C.DomainStrategy(s) { + case C.DomainStrategyAsIS: + value = "" + // value = "as_is" + case C.DomainStrategyPreferIPv4: + value = "prefer_ipv4" + case C.DomainStrategyPreferIPv6: + value = "prefer_ipv6" + case C.DomainStrategyIPv4Only: + value = "ipv4_only" + case C.DomainStrategyIPv6Only: + value = "ipv6_only" + default: + return nil, E.New("unknown domain strategy: ", s) + } + return json.Marshal(value) +} + +func (s *DomainStrategy) UnmarshalJSON(bytes []byte) error { + var value string + err := json.Unmarshal(bytes, &value) + if err != nil { + return err + } + switch value { + case "", "as_is": + *s = DomainStrategy(C.DomainStrategyAsIS) + case "prefer_ipv4": + *s = DomainStrategy(C.DomainStrategyPreferIPv4) + case "prefer_ipv6": + *s = DomainStrategy(C.DomainStrategyPreferIPv6) + case "ipv4_only": + *s = DomainStrategy(C.DomainStrategyIPv4Only) + case "ipv6_only": + *s = DomainStrategy(C.DomainStrategyIPv6Only) + default: + return E.New("unknown domain strategy: ", value) + } + return nil +} + +type DNSQueryType uint16 + +func (t DNSQueryType) String() string { + typeName, loaded := mDNS.TypeToString[uint16(t)] + if loaded { + return typeName + } + return F.ToString(uint16(t)) +} + +func (t DNSQueryType) MarshalJSON() ([]byte, error) { + typeName, loaded := mDNS.TypeToString[uint16(t)] + if loaded { + return json.Marshal(typeName) + } + return json.Marshal(uint16(t)) +} + +func (t *DNSQueryType) UnmarshalJSON(bytes []byte) error { + var valueNumber uint16 + err := json.Unmarshal(bytes, &valueNumber) + if err == nil { + *t = DNSQueryType(valueNumber) + return nil + } + var valueString string + err = json.Unmarshal(bytes, &valueString) + if err == nil { + queryType, loaded := mDNS.StringToType[valueString] + if loaded { + *t = DNSQueryType(queryType) + return nil + } + } + return E.New("unknown DNS query type: ", string(bytes)) +} + +func DNSQueryTypeToString(queryType uint16) string { + typeName, loaded := mDNS.TypeToString[queryType] + if loaded { + return typeName + } + return F.ToString(queryType) +} + +type NetworkStrategy C.NetworkStrategy + +func (n NetworkStrategy) MarshalJSON() ([]byte, error) { + return json.Marshal(C.NetworkStrategy(n).String()) +} + +func (n *NetworkStrategy) UnmarshalJSON(content []byte) error { + var value string + err := json.Unmarshal(content, &value) + if err != nil { + return err + } + strategy, loaded := C.StringToNetworkStrategy[value] + if !loaded { + return E.New("unknown network strategy: ", value) + } + *n = NetworkStrategy(strategy) + return nil +} + +type InterfaceType C.InterfaceType + +func (t InterfaceType) Build() C.InterfaceType { + return C.InterfaceType(t) +} + +func (t InterfaceType) MarshalJSON() ([]byte, error) { + return json.Marshal(C.InterfaceType(t).String()) +} + +func (t *InterfaceType) UnmarshalJSON(content []byte) error { + var value string + err := json.Unmarshal(content, &value) + if err != nil { + return err + } + interfaceType, loaded := C.StringToInterfaceType[value] + if !loaded { + return E.New("unknown interface type: ", value) + } + *t = InterfaceType(interfaceType) + return nil +} diff --git a/option/udp_over_tcp.go b/option/udp_over_tcp.go new file mode 100644 index 00000000..b496017a --- /dev/null +++ b/option/udp_over_tcp.go @@ -0,0 +1,30 @@ +package option + +import ( + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/uot" +) + +type _UDPOverTCPOptions struct { + Enabled bool `json:"enabled,omitempty"` + Version uint8 `json:"version,omitempty"` +} + +type UDPOverTCPOptions _UDPOverTCPOptions + +func (o UDPOverTCPOptions) MarshalJSON() ([]byte, error) { + switch o.Version { + case 0, uot.Version: + return json.Marshal(o.Enabled) + default: + return json.Marshal(_UDPOverTCPOptions(o)) + } +} + +func (o *UDPOverTCPOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, &o.Enabled) + if err == nil { + return nil + } + return json.UnmarshalDisallowUnknownFields(bytes, (*_UDPOverTCPOptions)(o)) +} diff --git a/option/v2ray.go b/option/v2ray.go new file mode 100644 index 00000000..774a651d --- /dev/null +++ b/option/v2ray.go @@ -0,0 +1 @@ +package option diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go new file mode 100644 index 00000000..68c23858 --- /dev/null +++ b/option/v2ray_transport.go @@ -0,0 +1,100 @@ +package option + +import ( + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +type _V2RayTransportOptions struct { + Type string `json:"type"` + HTTPOptions V2RayHTTPOptions `json:"-"` + WebsocketOptions V2RayWebsocketOptions `json:"-"` + QUICOptions V2RayQUICOptions `json:"-"` + GRPCOptions V2RayGRPCOptions `json:"-"` + HTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"` +} + +type V2RayTransportOptions _V2RayTransportOptions + +func (o V2RayTransportOptions) MarshalJSON() ([]byte, error) { + var v any + switch o.Type { + case C.V2RayTransportTypeHTTP: + v = o.HTTPOptions + case C.V2RayTransportTypeWebsocket: + v = o.WebsocketOptions + case C.V2RayTransportTypeQUIC: + v = o.QUICOptions + case C.V2RayTransportTypeGRPC: + v = o.GRPCOptions + case C.V2RayTransportTypeHTTPUpgrade: + v = o.HTTPUpgradeOptions + case "": + return nil, E.New("missing transport type") + default: + return nil, E.New("unknown transport type: " + o.Type) + } + return badjson.MarshallObjects((_V2RayTransportOptions)(o), v) +} + +func (o *V2RayTransportOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_V2RayTransportOptions)(o)) + if err != nil { + return err + } + var v any + switch o.Type { + case C.V2RayTransportTypeHTTP: + v = &o.HTTPOptions + case C.V2RayTransportTypeWebsocket: + v = &o.WebsocketOptions + case C.V2RayTransportTypeQUIC: + v = &o.QUICOptions + case C.V2RayTransportTypeGRPC: + v = &o.GRPCOptions + case C.V2RayTransportTypeHTTPUpgrade: + v = &o.HTTPUpgradeOptions + default: + return E.New("unknown transport type: " + o.Type) + } + err = badjson.UnmarshallExcluded(bytes, (*_V2RayTransportOptions)(o), v) + if err != nil { + return err + } + return nil +} + +type V2RayHTTPOptions struct { + Host badoption.Listable[string] `json:"host,omitempty"` + Path string `json:"path,omitempty"` + Method string `json:"method,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + PingTimeout badoption.Duration `json:"ping_timeout,omitempty"` +} + +type V2RayWebsocketOptions struct { + Path string `json:"path,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + MaxEarlyData uint32 `json:"max_early_data,omitempty"` + EarlyDataHeaderName string `json:"early_data_header_name,omitempty"` +} + +type V2RayQUICOptions struct{} + +type V2RayGRPCOptions struct { + ServiceName string `json:"service_name,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + PingTimeout badoption.Duration `json:"ping_timeout,omitempty"` + PermitWithoutStream bool `json:"permit_without_stream,omitempty"` + ForceLite bool `json:"-"` // for test +} + +type V2RayHTTPUpgradeOptions struct { + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` +} diff --git a/option/vless.go b/option/vless.go new file mode 100644 index 00000000..5acf2aee --- /dev/null +++ b/option/vless.go @@ -0,0 +1,27 @@ +package option + +type VLESSInboundOptions struct { + ListenOptions + Users []VLESSUser `json:"users,omitempty"` + InboundTLSOptionsContainer + Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` + Transport *V2RayTransportOptions `json:"transport,omitempty"` +} + +type VLESSUser struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Flow string `json:"flow,omitempty"` +} + +type VLESSOutboundOptions struct { + DialerOptions + ServerOptions + UUID string `json:"uuid"` + Flow string `json:"flow,omitempty"` + Network NetworkList `json:"network,omitempty"` + OutboundTLSOptionsContainer + Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` + Transport *V2RayTransportOptions `json:"transport,omitempty"` + PacketEncoding *string `json:"packet_encoding,omitempty"` +} diff --git a/option/vmess.go b/option/vmess.go new file mode 100644 index 00000000..8a38fb70 --- /dev/null +++ b/option/vmess.go @@ -0,0 +1,30 @@ +package option + +type VMessInboundOptions struct { + ListenOptions + Users []VMessUser `json:"users,omitempty"` + InboundTLSOptionsContainer + Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` + Transport *V2RayTransportOptions `json:"transport,omitempty"` +} + +type VMessUser struct { + Name string `json:"name"` + UUID string `json:"uuid"` + AlterId int `json:"alterId,omitempty"` +} + +type VMessOutboundOptions struct { + DialerOptions + ServerOptions + UUID string `json:"uuid"` + Security string `json:"security"` + AlterId int `json:"alter_id,omitempty"` + GlobalPadding bool `json:"global_padding,omitempty"` + AuthenticatedLength bool `json:"authenticated_length,omitempty"` + Network NetworkList `json:"network,omitempty"` + OutboundTLSOptionsContainer + PacketEncoding string `json:"packet_encoding,omitempty"` + Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` + Transport *V2RayTransportOptions `json:"transport,omitempty"` +} diff --git a/option/wireguard.go b/option/wireguard.go new file mode 100644 index 00000000..c86abd11 --- /dev/null +++ b/option/wireguard.go @@ -0,0 +1,30 @@ +package option + +import ( + "net/netip" + + "github.com/sagernet/sing/common/json/badoption" +) + +type WireGuardEndpointOptions struct { + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address"` + PrivateKey string `json:"private_key"` + ListenPort uint16 `json:"listen_port,omitempty"` + Peers []WireGuardPeer `json:"peers,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + Workers int `json:"workers,omitempty"` + DialerOptions +} + +type WireGuardPeer struct { + Address string `json:"address,omitempty"` + Port uint16 `json:"port,omitempty"` + PublicKey string `json:"public_key,omitempty"` + PreSharedKey string `json:"pre_shared_key,omitempty"` + AllowedIPs badoption.Listable[netip.Prefix] `json:"allowed_ips,omitempty"` + PersistentKeepaliveInterval uint16 `json:"persistent_keepalive_interval,omitempty"` + Reserved []uint8 `json:"reserved,omitempty"` +} diff --git a/protocol/anytls/inbound.go b/protocol/anytls/inbound.go new file mode 100644 index 00000000..52d77353 --- /dev/null +++ b/protocol/anytls/inbound.go @@ -0,0 +1,134 @@ +package anytls + +import ( + "context" + "net" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + anytls "github.com/anytls/sing-anytls" + "github.com/anytls/sing-anytls/padding" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.AnyTLSInboundOptions](registry, C.TypeAnyTLS, NewInbound) +} + +type Inbound struct { + inbound.Adapter + tlsConfig tls.ServerConfig + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service *anytls.Service +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag), + router: uot.NewRouter(router, logger), + logger: logger, + } + + if options.TLS != nil && options.TLS.Enabled { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + + paddingScheme := padding.DefaultPaddingScheme + if len(options.PaddingScheme) > 0 { + paddingScheme = []byte(strings.Join(options.PaddingScheme, "\n")) + } + + service, err := anytls.NewService(anytls.ServiceConfig{ + Users: common.Map(options.Users, func(it option.AnyTLSUser) anytls.User { + return (anytls.User)(it) + }), + PaddingScheme: paddingScheme, + Handler: (*inboundHandler)(inbound), + Logger: logger, + }) + if err != nil { + return nil, err + } + inbound.service = service + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return err + } + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return common.Close(h.listener, h.tlsConfig) +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if h.tlsConfig != nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return + } + conn = tlsConn + } + err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } +} + +type inboundHandler Inbound + +func (h *inboundHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.Source = source + metadata.Destination = destination.Unwrap() + if userName, _ := auth.UserFromContext[string](ctx); userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go new file mode 100644 index 00000000..2f24c2ef --- /dev/null +++ b/protocol/anytls/outbound.go @@ -0,0 +1,131 @@ +package anytls + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" + + anytls "github.com/anytls/sing-anytls" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.AnyTLSOutboundOptions](registry, C.TypeAnyTLS, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + dialer tls.Dialer + server M.Socksaddr + tlsConfig tls.Config + client *anytls.Client + uotClient *uot.Client + logger log.ContextLogger +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSOutboundOptions) (adapter.Outbound, error) { + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeAnyTLS, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + server: options.ServerOptions.Build(), + logger: logger, + } + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + // TCP Fast Open is incompatible with anytls because TFO creates a lazy connection + // that only establishes on first write. The lazy connection returns an empty address + // before establishment, but anytls SOCKS wrapper tries to access the remote address + // during handshake, causing a null pointer dereference crash. + if options.DialerOptions.TCPFastOpen { + return nil, E.New("tcp_fast_open is not supported with anytls outbound") + } + + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + outbound.tlsConfig = tlsConfig + + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain(), + }) + if err != nil { + return nil, err + } + + outbound.dialer = tls.NewDialer(outboundDialer, tlsConfig) + + client, err := anytls.NewClient(ctx, anytls.ClientConfig{ + Password: options.Password, + IdleSessionCheckInterval: options.IdleSessionCheckInterval.Build(), + IdleSessionTimeout: options.IdleSessionTimeout.Build(), + MinIdleSession: options.MinIdleSession, + DialOut: outbound.dialOut, + Logger: logger, + }) + if err != nil { + return nil, err + } + outbound.client = client + + outbound.uotClient = &uot.Client{ + Dialer: (anytlsDialer)(client.CreateProxy), + Version: uot.Version, + } + return outbound, nil +} + +type anytlsDialer func(ctx context.Context, destination M.Socksaddr) (net.Conn, error) + +func (d anytlsDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return d(ctx, destination) +} + +func (d anytlsDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +func (h *Outbound) dialOut(ctx context.Context) (net.Conn, error) { + return h.dialer.DialTLSContext(ctx, h.server) +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.CreateProxy(ctx, destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + return h.uotClient.DialContext(ctx, network, destination) + } + return nil, os.ErrInvalid +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + return h.uotClient.ListenPacket(ctx, destination) +} + +func (h *Outbound) Close() error { + return common.Close(h.client) +} diff --git a/protocol/block/outbound.go b/protocol/block/outbound.go new file mode 100644 index 00000000..fe1ccda7 --- /dev/null +++ b/protocol/block/outbound.go @@ -0,0 +1,42 @@ +package block + +import ( + "context" + "net" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.StubOptions](registry, C.TypeBlock, New) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger +} + +func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, _ option.StubOptions) (adapter.Outbound, error) { + return &Outbound{ + Adapter: outbound.NewAdapter(C.TypeBlock, tag, []string{N.NetworkTCP, N.NetworkUDP}, nil), + logger: logger, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + h.logger.InfoContext(ctx, "blocked connection to ", destination) + return nil, syscall.EPERM +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + h.logger.InfoContext(ctx, "blocked packet connection to ", destination) + return nil, syscall.EPERM +} diff --git a/protocol/cloudflare/inbound.go b/protocol/cloudflare/inbound.go new file mode 100644 index 00000000..f445ab95 --- /dev/null +++ b/protocol/cloudflare/inbound.go @@ -0,0 +1,160 @@ +//go:build with_cloudflared + +package cloudflare + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + boxDialer "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + cloudflared "github.com/sagernet/sing-cloudflared" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/pipe" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.CloudflaredInboundOptions](registry, C.TypeCloudflared, NewInbound) +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CloudflaredInboundOptions) (adapter.Inbound, error) { + controlDialer, err := boxDialer.NewWithOptions(boxDialer.Options{ + Context: ctx, + Options: options.ControlDialer, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "build cloudflared control dialer") + } + tunnelDialer, err := boxDialer.NewWithOptions(boxDialer.Options{ + Context: ctx, + Options: options.TunnelDialer, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "build cloudflared tunnel dialer") + } + + service, err := cloudflared.NewService(cloudflared.ServiceOptions{ + Logger: logger, + ConnectionDialer: &routerDialer{router: router, tag: tag}, + ControlDialer: controlDialer, + TunnelDialer: tunnelDialer, + ICMPHandler: &icmpRouterHandler{router: router, logger: logger, tag: tag}, + ConnContext: func(connCtx context.Context) context.Context { + return adapter.WithContext(connCtx, &adapter.InboundContext{ + Inbound: tag, + InboundType: C.TypeCloudflared, + }) + }, + Token: options.Token, + HAConnections: options.HighAvailabilityConnections, + Protocol: options.Protocol, + PostQuantum: options.PostQuantum, + EdgeIPVersion: options.EdgeIPVersion, + DatagramVersion: options.DatagramVersion, + GracePeriod: time.Duration(options.GracePeriod), + Region: options.Region, + }) + if err != nil { + return nil, err + } + + return &Inbound{ + Adapter: inbound.NewAdapter(C.TypeCloudflared, tag), + service: service, + }, nil +} + +type Inbound struct { + inbound.Adapter + service *cloudflared.Service +} + +func (i *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return i.service.Start() +} + +func (i *Inbound) Close() error { + return i.service.Close() +} + +type routerDialer struct { + router adapter.Router + tag string +} + +func (d *routerDialer) newMetadata(network string, destination M.Socksaddr) adapter.InboundContext { + return adapter.InboundContext{ + Inbound: d.tag, + InboundType: C.TypeCloudflared, + Network: network, + Destination: destination, + } +} + +func (d *routerDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + input, output := pipe.Pipe() + go d.router.RouteConnectionEx(ctx, output, d.newMetadata(N.NetworkTCP, destination), N.OnceClose(func(it error) { + input.Close() + })) + return input, nil +} + +func (d *routerDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + input, output := pipe.Pipe() + routerConn := bufio.NewUnbindPacketConn(output) + go d.router.RoutePacketConnectionEx(ctx, routerConn, d.newMetadata(N.NetworkUDP, destination), N.OnceClose(func(it error) { + input.Close() + })) + return bufio.NewUnbindPacketConn(input), nil +} + +type icmpRouterHandler struct { + router adapter.Router + logger log.ContextLogger + tag string +} + +func (h *icmpRouterHandler) RouteICMPConnection(ctx context.Context, session tun.DirectRouteSession, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + var ipVersion uint8 + if session.Destination.Is4() { + ipVersion = 4 + } else { + ipVersion = 6 + } + destination := M.SocksaddrFrom(session.Destination, 0) + routeDestination, err := h.router.PreMatch(adapter.InboundContext{ + Inbound: h.tag, + InboundType: C.TypeCloudflared, + IPVersion: ipVersion, + Network: N.NetworkICMP, + Source: M.SocksaddrFrom(session.Source, 0), + Destination: destination, + OriginDestination: destination, + }, routeContext, timeout, false) + if err != nil { + switch { + case rule.IsBypassed(err): + err = nil + case rule.IsRejected(err): + h.logger.Trace("reject ICMP connection from ", session.Source, " to ", session.Destination) + default: + h.logger.Warn(E.Cause(err, "link ICMP connection from ", session.Source, " to ", session.Destination)) + } + } + return routeDestination, err +} diff --git a/protocol/direct/inbound.go b/protocol/direct/inbound.go new file mode 100644 index 00000000..81353b65 --- /dev/null +++ b/protocol/direct/inbound.go @@ -0,0 +1,144 @@ +package direct + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/udpnat2" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.DirectInboundOptions](registry, C.TypeDirect, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + udpNat *udpnat.Service + overrideOption int + overrideDestination M.Socksaddr +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.DirectInboundOptions) (adapter.Inbound, error) { + options.UDPFragmentDefault = true + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeDirect, tag), + ctx: ctx, + router: router, + logger: logger, + } + if options.OverrideAddress != "" && options.OverridePort != 0 { + inbound.overrideOption = 1 + inbound.overrideDestination = M.ParseSocksaddrHostPort(options.OverrideAddress, options.OverridePort) + } else if options.OverrideAddress != "" { + inbound.overrideOption = 2 + inbound.overrideDestination = M.ParseSocksaddrHostPort(options.OverrideAddress, options.OverridePort) + } else if options.OverridePort != 0 { + inbound.overrideOption = 3 + inbound.overrideDestination = M.Socksaddr{Port: options.OverridePort} + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + inbound.udpNat = udpnat.New(inbound, inbound.preparePacketConnection, udpTimeout, false) + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: options.Network.Build(), + Listen: options.ListenOptions, + ConnectionHandler: inbound, + PacketHandler: inbound, + }) + return inbound, nil +} + +func (i *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return i.listener.Start() +} + +func (i *Inbound) Close() error { + return i.listener.Close() +} + +func (i *Inbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) { + i.udpNat.NewPacket([][]byte{buffer.Bytes()}, source, i.listener.UDPAddr(), nil) +} + +func (i *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = i.Tag() + metadata.InboundType = i.Type() + destination := metadata.OriginDestination + switch i.overrideOption { + case 1: + destination = i.overrideDestination + case 2: + destination.Addr = i.overrideDestination.Addr + case 3: + destination.Port = i.overrideDestination.Port + } + metadata.Destination = destination + if i.overrideOption != 0 { + i.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + i.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (i *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + i.logger.InfoContext(ctx, "inbound packet connection from ", source) + var metadata adapter.InboundContext + metadata.Inbound = i.Tag() + metadata.InboundType = i.Type() + //nolint:staticcheck + metadata.InboundDetour = i.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.Source = source + destination = i.listener.UDPAddr() + switch i.overrideOption { + case 1: + destination = i.overrideDestination + case 2: + destination.Addr = i.overrideDestination.Addr + case 3: + destination.Port = i.overrideDestination.Port + default: + } + i.logger.InfoContext(ctx, "inbound packet connection to ", destination) + metadata.Destination = destination + if i.overrideOption != 0 { + conn = bufio.NewDestinationNATPacketConn(bufio.NewNetPacketConn(conn), i.listener.UDPAddr(), destination) + } + i.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (i *Inbound) preparePacketConnection(source M.Socksaddr, destination M.Socksaddr, userData any) (bool, context.Context, N.PacketWriter, N.CloseHandlerFunc) { + return true, log.ContextWithNewID(i.ctx), &directPacketWriter{i.listener.PacketWriter(), source}, nil +} + +type directPacketWriter struct { + writer N.PacketWriter + source M.Socksaddr +} + +func (w *directPacketWriter) WritePacket(buffer *buf.Buffer, addr M.Socksaddr) error { + return w.writer.WritePacket(buffer, w.source) +} diff --git a/protocol/direct/loopback_detect.go b/protocol/direct/loopback_detect.go new file mode 100644 index 00000000..7a62164e --- /dev/null +++ b/protocol/direct/loopback_detect.go @@ -0,0 +1,186 @@ +package direct + +import ( + "net" + "net/netip" + "sync" + + "github.com/sagernet/sing-box/adapter" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type loopBackDetector struct { + networkManager adapter.NetworkManager + connAccess sync.RWMutex + packetConnAccess sync.RWMutex + connMap map[netip.AddrPort]netip.AddrPort + packetConnMap map[uint16]uint16 +} + +func newLoopBackDetector(networkManager adapter.NetworkManager) *loopBackDetector { + return &loopBackDetector{ + networkManager: networkManager, + connMap: make(map[netip.AddrPort]netip.AddrPort), + packetConnMap: make(map[uint16]uint16), + } +} + +func (l *loopBackDetector) NewConn(conn net.Conn) net.Conn { + source := M.AddrPortFromNet(conn.LocalAddr()) + if !source.IsValid() { + return conn + } + if udpConn, isUDPConn := conn.(abstractUDPConn); isUDPConn { + if !source.Addr().IsLoopback() { + _, err := l.networkManager.InterfaceFinder().ByAddr(source.Addr()) + if err != nil { + return conn + } + } + if !N.IsPublicAddr(source.Addr()) { + return conn + } + l.packetConnAccess.Lock() + l.packetConnMap[source.Port()] = M.AddrPortFromNet(conn.RemoteAddr()).Port() + l.packetConnAccess.Unlock() + return &loopBackDetectUDPWrapper{abstractUDPConn: udpConn, detector: l, connPort: source.Port()} + } else { + l.connAccess.Lock() + l.connMap[source] = M.AddrPortFromNet(conn.RemoteAddr()) + l.connAccess.Unlock() + return &loopBackDetectWrapper{Conn: conn, detector: l, connAddr: source} + } +} + +func (l *loopBackDetector) NewPacketConn(conn N.NetPacketConn, destination M.Socksaddr) N.NetPacketConn { + source := M.AddrPortFromNet(conn.LocalAddr()) + if !source.IsValid() { + return conn + } + if !source.Addr().IsLoopback() { + _, err := l.networkManager.InterfaceFinder().ByAddr(source.Addr()) + if err != nil { + return conn + } + } + l.packetConnAccess.Lock() + l.packetConnMap[source.Port()] = destination.AddrPort().Port() + l.packetConnAccess.Unlock() + return &loopBackDetectPacketWrapper{NetPacketConn: conn, detector: l, connPort: source.Port()} +} + +func (l *loopBackDetector) CheckConn(source netip.AddrPort, local netip.AddrPort) bool { + l.connAccess.RLock() + defer l.connAccess.RUnlock() + destination, loaded := l.connMap[source] + return loaded && destination != local +} + +func (l *loopBackDetector) CheckPacketConn(source netip.AddrPort, local netip.AddrPort) bool { + if !source.IsValid() { + return false + } + if !source.Addr().IsLoopback() { + _, err := l.networkManager.InterfaceFinder().ByAddr(source.Addr()) + if err != nil { + return false + } + } + if N.IsPublicAddr(source.Addr()) { + return false + } + l.packetConnAccess.RLock() + defer l.packetConnAccess.RUnlock() + destinationPort, loaded := l.packetConnMap[source.Port()] + return loaded && destinationPort != local.Port() +} + +type loopBackDetectWrapper struct { + net.Conn + detector *loopBackDetector + connAddr netip.AddrPort + closeOnce sync.Once +} + +func (w *loopBackDetectWrapper) Close() error { + w.closeOnce.Do(func() { + w.detector.connAccess.Lock() + delete(w.detector.connMap, w.connAddr) + w.detector.connAccess.Unlock() + }) + return w.Conn.Close() +} + +func (w *loopBackDetectWrapper) ReaderReplaceable() bool { + return true +} + +func (w *loopBackDetectWrapper) WriterReplaceable() bool { + return true +} + +func (w *loopBackDetectWrapper) Upstream() any { + return w.Conn +} + +type loopBackDetectPacketWrapper struct { + N.NetPacketConn + detector *loopBackDetector + connPort uint16 + closeOnce sync.Once +} + +func (w *loopBackDetectPacketWrapper) Close() error { + w.closeOnce.Do(func() { + w.detector.packetConnAccess.Lock() + delete(w.detector.packetConnMap, w.connPort) + w.detector.packetConnAccess.Unlock() + }) + return w.NetPacketConn.Close() +} + +func (w *loopBackDetectPacketWrapper) ReaderReplaceable() bool { + return true +} + +func (w *loopBackDetectPacketWrapper) WriterReplaceable() bool { + return true +} + +func (w *loopBackDetectPacketWrapper) Upstream() any { + return w.NetPacketConn +} + +type abstractUDPConn interface { + net.Conn + net.PacketConn +} + +type loopBackDetectUDPWrapper struct { + abstractUDPConn + detector *loopBackDetector + connPort uint16 + closeOnce sync.Once +} + +func (w *loopBackDetectUDPWrapper) Close() error { + w.closeOnce.Do(func() { + w.detector.packetConnAccess.Lock() + delete(w.detector.packetConnMap, w.connPort) + w.detector.packetConnAccess.Unlock() + }) + return w.abstractUDPConn.Close() +} + +func (w *loopBackDetectUDPWrapper) ReaderReplaceable() bool { + return true +} + +func (w *loopBackDetectUDPWrapper) WriterReplaceable() bool { + return true +} + +func (w *loopBackDetectUDPWrapper) Upstream() any { + return w.abstractUDPConn +} diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go new file mode 100644 index 00000000..9d24f31a --- /dev/null +++ b/protocol/direct/outbound.go @@ -0,0 +1,178 @@ +package direct + +import ( + "context" + "net" + "net/netip" + "reflect" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/ping" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.DirectOutboundOptions](registry, C.TypeDirect, NewOutbound) +} + +var ( + _ N.ParallelDialer = (*Outbound)(nil) + _ dialer.ParallelNetworkDialer = (*Outbound)(nil) + _ dialer.DirectDialer = (*Outbound)(nil) + _ adapter.DirectRouteOutbound = (*Outbound)(nil) +) + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + dialer dialer.ParallelInterfaceDialer + domainStrategy C.DomainStrategy + fallbackDelay time.Duration + isEmpty bool + // loopBack *loopBackDetector +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.DirectOutboundOptions) (adapter.Outbound, error) { + options.UDPFragmentDefault = true + if options.Detour != "" { + return nil, E.New("`detour` is not supported in direct context") + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: true, + DirectOutbound: true, + }) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeDirect, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + ctx: ctx, + logger: logger, + //nolint:staticcheck + domainStrategy: C.DomainStrategy(options.DomainStrategy), + fallbackDelay: time.Duration(options.FallbackDelay), + dialer: outboundDialer.(dialer.ParallelInterfaceDialer), + isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), + // loopBack: newLoopBackDetector(router), + } + //nolint:staticcheck + if options.ProxyProtocol != 0 { + return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + network = N.NetworkName(network) + switch network { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + /*conn, err := h.dialer.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + return h.loopBack.NewConn(conn), nil*/ + return h.dialer.DialContext(ctx, network, destination) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound packet connection") + conn, err := h.dialer.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + // conn = h.loopBack.NewPacketConn(bufio.NewPacketConn(conn), destination) + return conn, nil +} + +func (h *Outbound) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + ctx := log.ContextWithNewID(h.ctx) + destination, err := ping.ConnectDestination(ctx, h.logger, common.MustCast[*dialer.DefaultDialer](h.dialer).DialerForICMPDestination(metadata.Destination.Addr).Control, metadata.Destination.Addr, routeContext, timeout) + if err != nil { + return nil, err + } + h.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString()) + return destination, nil +} + +func (h *Outbound) DialParallel(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + network = N.NetworkName(network) + switch network { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), nil, nil, nil, h.fallbackDelay) +} + +func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy *C.NetworkStrategy, networkType []C.InterfaceType, fallbackNetworkType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + network = N.NetworkName(network) + switch network { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), networkStrategy, networkType, fallbackNetworkType, fallbackDelay) +} + +func (h *Outbound) ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy *C.NetworkStrategy, networkType []C.InterfaceType, fallbackNetworkType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound packet connection") + conn, newDestination, err := dialer.ListenSerialNetworkPacket(ctx, h.dialer, destination, destinationAddresses, networkStrategy, networkType, fallbackNetworkType, fallbackDelay) + if err != nil { + return nil, netip.Addr{}, err + } + return conn, newDestination, nil +} + +func (h *Outbound) IsEmpty() bool { + return h.isEmpty +} + +/*func (h *Outbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + if h.loopBack.CheckConn(metadata.Source.AddrPort(), M.AddrPortFromNet(conn.LocalAddr())) { + return E.New("reject loopback connection to ", metadata.Destination) + } + return NewConnection(ctx, h, conn, metadata) +} + +func (h *Outbound) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + if h.loopBack.CheckPacketConn(metadata.Source.AddrPort(), M.AddrPortFromNet(conn.LocalAddr())) { + return E.New("reject loopback packet connection to ", metadata.Destination) + } + return NewPacketConnection(ctx, h, conn, metadata) +} +*/ diff --git a/protocol/dns/handle.go b/protocol/dns/handle.go new file mode 100644 index 00000000..e1323509 --- /dev/null +++ b/protocol/dns/handle.go @@ -0,0 +1,220 @@ +package dns + +import ( + "context" + "encoding/binary" + "net" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/canceler" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/task" + + mDNS "github.com/miekg/dns" +) + +func HandleStreamDNSRequest(ctx context.Context, router adapter.DNSRouter, conn net.Conn, metadata adapter.InboundContext) error { + var queryLength uint16 + err := binary.Read(conn, binary.BigEndian, &queryLength) + if err != nil { + return err + } + if queryLength == 0 { + return dns.RcodeFormatError + } + buffer := buf.NewSize(int(queryLength)) + defer buffer.Release() + _, err = buffer.ReadFullFrom(conn, int(queryLength)) + if err != nil { + return err + } + var message mDNS.Msg + err = message.Unpack(buffer.Bytes()) + if err != nil { + return err + } + metadataInQuery := metadata + go func() error { + response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + if err != nil { + conn.Close() + return err + } + responseLength := response.Len() + responseBuffer := buf.NewSize(3 + responseLength) + defer responseBuffer.Release() + responseBuffer.Resize(2, 0) + n, err := response.PackBuffer(responseBuffer.FreeBytes()) + if err != nil { + return err + } + responseBuffer.Truncate(len(n)) + binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n))) + _, err = conn.Write(responseBuffer.Bytes()) + return err + }() + return nil +} + +func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, cachedPackets []*N.PacketBuffer, metadata adapter.InboundContext) error { + metadata.Destination = M.Socksaddr{} + var reader N.PacketReader = conn + var counters []N.CountFunc + cachedPackets = common.Reverse(cachedPackets) + for { + reader, counters = N.UnwrapCountPacketReader(reader, counters) + if cachedReader, isCached := reader.(N.CachedPacketReader); isCached { + packet := cachedReader.ReadCachedPacket() + if packet != nil { + cachedPackets = append(cachedPackets, packet) + continue + } + } + if readWaiter, created := bufio.CreatePacketReadWaiter(reader); created { + readWaiter.InitializeReadWaiter(N.ReadWaitOptions{}) + return newDNSPacketConnection(ctx, router, conn, readWaiter, counters, cachedPackets, metadata) + } + break + } + fastClose, cancel := common.ContextWithCancelCause(ctx) + timeout := canceler.New(fastClose, cancel, C.DNSTimeout) + var group task.Group + group.Append0(func(_ context.Context) error { + for { + var message mDNS.Msg + var destination M.Socksaddr + var err error + if len(cachedPackets) > 0 { + packet := cachedPackets[0] + cachedPackets = cachedPackets[1:] + for _, counter := range counters { + counter(int64(packet.Buffer.Len())) + } + err = message.Unpack(packet.Buffer.Bytes()) + packet.Buffer.Release() + if err != nil { + cancel(err) + return err + } + destination = packet.Destination + } else { + buffer := buf.NewPacket() + destination, err = conn.ReadPacket(buffer) + if err != nil { + buffer.Release() + cancel(err) + return err + } + for _, counter := range counters { + counter(int64(buffer.Len())) + } + err = message.Unpack(buffer.Bytes()) + buffer.Release() + if err != nil { + cancel(err) + return err + } + timeout.Update() + } + metadataInQuery := metadata + go func() error { + response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + if err != nil { + cancel(err) + return err + } + timeout.Update() + responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) + if err != nil { + cancel(err) + return err + } + err = conn.WritePacket(responseBuffer, destination) + if err != nil { + cancel(err) + } + return err + }() + } + }) + group.Cleanup(func() { + conn.Close() + }) + return group.Run(fastClose) +} + +func newDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, readWaiter N.PacketReadWaiter, readCounters []N.CountFunc, cached []*N.PacketBuffer, metadata adapter.InboundContext) error { + fastClose, cancel := common.ContextWithCancelCause(ctx) + timeout := canceler.New(fastClose, cancel, C.DNSTimeout) + var group task.Group + group.Append0(func(_ context.Context) error { + for { + var ( + message mDNS.Msg + destination M.Socksaddr + err error + buffer *buf.Buffer + ) + if len(cached) > 0 { + packet := cached[0] + cached = cached[1:] + for _, counter := range readCounters { + counter(int64(packet.Buffer.Len())) + } + err = message.Unpack(packet.Buffer.Bytes()) + packet.Buffer.Release() + destination = packet.Destination + N.PutPacketBuffer(packet) + if err != nil { + cancel(err) + return err + } + } else { + buffer, destination, err = readWaiter.WaitReadPacket() + if err != nil { + cancel(err) + return err + } + for _, counter := range readCounters { + counter(int64(buffer.Len())) + } + err = message.Unpack(buffer.Bytes()) + buffer.Release() + if err != nil { + cancel(err) + return err + } + timeout.Update() + } + metadataInQuery := metadata + go func() error { + response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + if err != nil { + cancel(err) + return err + } + timeout.Update() + responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) + if err != nil { + cancel(err) + return err + } + err = conn.WritePacket(responseBuffer, destination) + if err != nil { + cancel(err) + } + return err + }() + } + }) + group.Cleanup(func() { + conn.Close() + }) + return group.Run(fastClose) +} diff --git a/protocol/dns/outbound.go b/protocol/dns/outbound.go new file mode 100644 index 00000000..277d7454 --- /dev/null +++ b/protocol/dns/outbound.go @@ -0,0 +1,63 @@ +package dns + +import ( + "context" + "net" + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.StubOptions](registry, C.TypeDNS, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + router adapter.DNSRouter + logger logger.ContextLogger +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.StubOptions) (adapter.Outbound, error) { + return &Outbound{ + Adapter: outbound.NewAdapter(C.TypeDNS, tag, []string{N.NetworkTCP, N.NetworkUDP}, nil), + router: service.FromContext[adapter.DNSRouter](ctx), + logger: logger, + }, nil +} + +func (d *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return nil, os.ErrInvalid +} + +func (d *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +func (d *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Destination = M.Socksaddr{} + for { + conn.SetReadDeadline(time.Now().Add(C.DNSTimeout)) + err := HandleStreamDNSRequest(ctx, d.router, conn, metadata) + if err != nil { + conn.Close() + if onClose != nil { + onClose(err) + } + return + } + } +} + +func (d *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + NewDNSPacketConnection(ctx, d.router, conn, nil, metadata) +} diff --git a/protocol/group/selector.go b/protocol/group/selector.go new file mode 100644 index 00000000..f3f7377b --- /dev/null +++ b/protocol/group/selector.go @@ -0,0 +1,192 @@ +package group + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/interrupt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterSelector(registry *outbound.Registry) { + outbound.Register[option.SelectorOutboundOptions](registry, C.TypeSelector, NewSelector) +} + +var ( + _ adapter.OutboundGroup = (*Selector)(nil) + _ adapter.ConnectionHandlerEx = (*Selector)(nil) + _ adapter.PacketConnectionHandlerEx = (*Selector)(nil) +) + +type Selector struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + tags []string + defaultTag string + outbounds map[string]adapter.Outbound + selected common.TypedValue[adapter.Outbound] + interruptGroup *interrupt.Group + interruptExternalConnections bool +} + +func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (adapter.Outbound, error) { + outbound := &Selector{ + Adapter: outbound.NewAdapter(C.TypeSelector, tag, nil, options.Outbounds), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + tags: options.Outbounds, + defaultTag: options.Default, + outbounds: make(map[string]adapter.Outbound), + interruptGroup: interrupt.NewGroup(), + interruptExternalConnections: options.InterruptExistConnections, + } + if len(outbound.tags) == 0 { + return nil, E.New("missing tags") + } + return outbound, nil +} + +func (s *Selector) Network() []string { + selected := s.selected.Load() + if selected == nil { + return []string{N.NetworkTCP, N.NetworkUDP} + } + return selected.Network() +} + +func (s *Selector) Start() error { + for i, tag := range s.tags { + detour, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + s.outbounds[tag] = detour + } + + if s.Tag() != "" { + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + selected := cacheFile.LoadSelected(s.Tag()) + if selected != "" { + detour, loaded := s.outbounds[selected] + if loaded { + s.selected.Store(detour) + return nil + } + } + } + } + + if s.defaultTag != "" { + detour, loaded := s.outbounds[s.defaultTag] + if !loaded { + return E.New("default outbound not found: ", s.defaultTag) + } + s.selected.Store(detour) + return nil + } + + s.selected.Store(s.outbounds[s.tags[0]]) + return nil +} + +func (s *Selector) Now() string { + selected := s.selected.Load() + if selected == nil { + return s.tags[0] + } + return selected.Tag() +} + +func (s *Selector) All() []string { + return s.tags +} + +func (s *Selector) SelectOutbound(tag string) bool { + detour, loaded := s.outbounds[tag] + if !loaded { + return false + } + if s.selected.Swap(detour) == detour { + return true + } + if s.Tag() != "" { + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + err := cacheFile.StoreSelected(s.Tag(), tag) + if err != nil { + s.logger.Error("store selected: ", err) + } + } + } + s.interruptGroup.Interrupt(s.interruptExternalConnections) + return true +} + +func (s *Selector) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + conn, err := s.selected.Load().DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil +} + +func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + conn, err := s.selected.Load().ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil +} + +func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + selected := s.selected.Load() + if outboundHandler, isHandler := selected.(adapter.ConnectionHandlerEx); isHandler { + outboundHandler.NewConnectionEx(ctx, conn, metadata, onClose) + } else { + s.connection.NewConnection(ctx, selected, conn, metadata, onClose) + } +} + +func (s *Selector) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + selected := s.selected.Load() + if outboundHandler, isHandler := selected.(adapter.PacketConnectionHandlerEx); isHandler { + outboundHandler.NewPacketConnectionEx(ctx, conn, metadata, onClose) + } else { + s.connection.NewPacketConnection(ctx, selected, conn, metadata, onClose) + } +} + +func (s *Selector) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + selected := s.selected.Load() + if !common.Contains(selected.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag()) + } + return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) +} + +func RealTag(detour adapter.Outbound) string { + if group, isGroup := detour.(adapter.OutboundGroup); isGroup { + return group.Now() + } + return detour.Tag() +} diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go new file mode 100644 index 00000000..26967279 --- /dev/null +++ b/protocol/group/urltest.go @@ -0,0 +1,429 @@ +package group + +import ( + "context" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/interrupt" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/batch" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" +) + +func RegisterURLTest(registry *outbound.Registry) { + outbound.Register[option.URLTestOutboundOptions](registry, C.TypeURLTest, NewURLTest) +} + +var _ adapter.OutboundGroup = (*URLTest)(nil) + +type URLTest struct { + outbound.Adapter + ctx context.Context + router adapter.Router + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger log.ContextLogger + tags []string + link string + interval time.Duration + tolerance uint16 + idleTimeout time.Duration + group *URLTestGroup + interruptExternalConnections bool +} + +func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { + outbound := &URLTest{ + Adapter: outbound.NewAdapter(C.TypeURLTest, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + ctx: ctx, + router: router, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + tags: options.Outbounds, + link: options.URL, + interval: time.Duration(options.Interval), + tolerance: options.Tolerance, + idleTimeout: time.Duration(options.IdleTimeout), + interruptExternalConnections: options.InterruptExistConnections, + } + if len(outbound.tags) == 0 { + return nil, E.New("missing tags") + } + return outbound, nil +} + +func (s *URLTest) Start() error { + outbounds := make([]adapter.Outbound, 0, len(s.tags)) + for i, tag := range s.tags { + detour, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + outbounds = append(outbounds, detour) + } + group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) + if err != nil { + return err + } + s.group = group + return nil +} + +func (s *URLTest) PostStart() error { + s.group.PostStart() + return nil +} + +func (s *URLTest) Close() error { + return common.Close( + common.PtrOrNil(s.group), + ) +} + +func (s *URLTest) Now() string { + if s.group.selectedOutboundTCP != nil { + return s.group.selectedOutboundTCP.Tag() + } else if s.group.selectedOutboundUDP != nil { + return s.group.selectedOutboundUDP.Tag() + } + return "" +} + +func (s *URLTest) All() []string { + return s.tags +} + +func (s *URLTest) URLTest(ctx context.Context) (map[string]uint16, error) { + return s.group.URLTest(ctx) +} + +func (s *URLTest) CheckOutbounds() { + s.group.CheckOutbounds(true) +} + +func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + s.group.Touch() + var outbound adapter.Outbound + switch N.NetworkName(network) { + case N.NetworkTCP: + outbound = s.group.selectedOutboundTCP + case N.NetworkUDP: + outbound = s.group.selectedOutboundUDP + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } + if outbound == nil { + outbound, _ = s.group.Select(network) + } + if outbound == nil { + return nil, E.New("missing supported outbound") + } + conn, err := outbound.DialContext(ctx, network, destination) + if err == nil { + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + } + s.logger.ErrorContext(ctx, err) + s.group.history.DeleteURLTestHistory(outbound.Tag()) + return nil, err +} + +func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + s.group.Touch() + outbound := s.group.selectedOutboundUDP + if outbound == nil { + outbound, _ = s.group.Select(N.NetworkUDP) + } + if outbound == nil { + return nil, E.New("missing supported outbound") + } + conn, err := outbound.ListenPacket(ctx, destination) + if err == nil { + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + } + s.logger.ErrorContext(ctx, err) + s.group.history.DeleteURLTestHistory(outbound.Tag()) + return nil, err +} + +func (s *URLTest) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + s.connection.NewConnection(ctx, s, conn, metadata, onClose) +} + +func (s *URLTest) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose) +} + +func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + s.group.Touch() + selected := s.group.selectedOutboundTCP + if selected == nil { + selected, _ = s.group.Select(N.NetworkTCP) + } + if selected == nil { + return nil, E.New("missing supported outbound") + } + if !common.Contains(selected.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag()) + } + return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) +} + +type URLTestGroup struct { + ctx context.Context + router adapter.Router + outbound adapter.OutboundManager + pause pause.Manager + pauseCallback *list.Element[pause.Callback] + logger log.Logger + outbounds []adapter.Outbound + link string + interval time.Duration + tolerance uint16 + idleTimeout time.Duration + history adapter.URLTestHistoryStorage + checking atomic.Bool + selectedOutboundTCP adapter.Outbound + selectedOutboundUDP adapter.Outbound + interruptGroup *interrupt.Group + interruptExternalConnections bool + access sync.Mutex + ticker *time.Ticker + close chan struct{} + started bool + lastActive common.TypedValue[time.Time] +} + +func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool) (*URLTestGroup, error) { + if interval == 0 { + interval = C.DefaultURLTestInterval + } + if tolerance == 0 { + tolerance = 50 + } + if idleTimeout == 0 { + idleTimeout = C.DefaultURLTestIdleTimeout + } + if interval > idleTimeout { + return nil, E.New("interval must be less or equal than idle_timeout") + } + var history adapter.URLTestHistoryStorage + if historyFromCtx := service.PtrFromContext[urltest.HistoryStorage](ctx); historyFromCtx != nil { + history = historyFromCtx + } else if clashServer := service.FromContext[adapter.ClashServer](ctx); clashServer != nil { + history = clashServer.HistoryStorage() + } else { + history = urltest.NewHistoryStorage() + } + return &URLTestGroup{ + ctx: ctx, + outbound: outboundManager, + logger: logger, + outbounds: outbounds, + link: link, + interval: interval, + tolerance: tolerance, + idleTimeout: idleTimeout, + history: history, + close: make(chan struct{}), + pause: service.FromContext[pause.Manager](ctx), + interruptGroup: interrupt.NewGroup(), + interruptExternalConnections: interruptExternalConnections, + }, nil +} + +func (g *URLTestGroup) PostStart() { + g.access.Lock() + defer g.access.Unlock() + g.started = true + g.lastActive.Store(time.Now()) + go g.CheckOutbounds(false) +} + +func (g *URLTestGroup) Touch() { + if !g.started { + return + } + g.access.Lock() + defer g.access.Unlock() + if g.ticker != nil { + g.lastActive.Store(time.Now()) + return + } + g.ticker = time.NewTicker(g.interval) + go g.loopCheck() + g.pauseCallback = pause.RegisterTicker(g.pause, g.ticker, g.interval, nil) +} + +func (g *URLTestGroup) Close() error { + g.access.Lock() + defer g.access.Unlock() + if g.ticker == nil { + return nil + } + g.ticker.Stop() + g.pause.UnregisterCallback(g.pauseCallback) + close(g.close) + return nil +} + +func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { + var minDelay uint16 + var minOutbound adapter.Outbound + switch network { + case N.NetworkTCP: + if g.selectedOutboundTCP != nil { + if history := g.history.LoadURLTestHistory(RealTag(g.selectedOutboundTCP)); history != nil { + minOutbound = g.selectedOutboundTCP + minDelay = history.Delay + } + } + case N.NetworkUDP: + if g.selectedOutboundUDP != nil { + if history := g.history.LoadURLTestHistory(RealTag(g.selectedOutboundUDP)); history != nil { + minOutbound = g.selectedOutboundUDP + minDelay = history.Delay + } + } + } + for _, detour := range g.outbounds { + if !common.Contains(detour.Network(), network) { + continue + } + history := g.history.LoadURLTestHistory(RealTag(detour)) + if history == nil { + continue + } + if minDelay == 0 || minDelay > history.Delay+g.tolerance { + minDelay = history.Delay + minOutbound = detour + } + } + if minOutbound == nil { + for _, detour := range g.outbounds { + if !common.Contains(detour.Network(), network) { + continue + } + return detour, false + } + return nil, false + } + return minOutbound, true +} + +func (g *URLTestGroup) loopCheck() { + if time.Since(g.lastActive.Load()) > g.interval { + g.lastActive.Store(time.Now()) + g.CheckOutbounds(false) + } + for { + select { + case <-g.close: + return + case <-g.ticker.C: + } + if time.Since(g.lastActive.Load()) > g.idleTimeout { + g.access.Lock() + g.ticker.Stop() + g.ticker = nil + g.pause.UnregisterCallback(g.pauseCallback) + g.pauseCallback = nil + g.access.Unlock() + return + } + g.CheckOutbounds(false) + } +} + +func (g *URLTestGroup) CheckOutbounds(force bool) { + _, _ = g.urlTest(g.ctx, force) +} + +func (g *URLTestGroup) URLTest(ctx context.Context) (map[string]uint16, error) { + return g.urlTest(ctx, false) +} + +func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint16, error) { + result := make(map[string]uint16) + if g.checking.Swap(true) { + return result, nil + } + defer g.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + checked := make(map[string]bool) + var resultAccess sync.Mutex + for _, detour := range g.outbounds { + tag := detour.Tag() + realTag := RealTag(detour) + if checked[realTag] { + continue + } + history := g.history.LoadURLTestHistory(realTag) + if !force && history != nil && time.Since(history.Time) < g.interval { + continue + } + checked[realTag] = true + p, loaded := g.outbound.Outbound(realTag) + if !loaded { + continue + } + b.Go(realTag, func() (any, error) { + testCtx, cancel := context.WithTimeout(g.ctx, C.TCPTimeout) + defer cancel() + t, err := urltest.URLTest(testCtx, g.link, p) + if err != nil { + g.logger.Debug("outbound ", tag, " unavailable: ", err) + g.history.DeleteURLTestHistory(realTag) + } else { + g.logger.Debug("outbound ", tag, " available: ", t, "ms") + g.history.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + g.performUpdateCheck() + return result, nil +} + +func (g *URLTestGroup) performUpdateCheck() { + var updated bool + if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (g.selectedOutboundTCP == nil || (exists && outbound != g.selectedOutboundTCP)) { + if g.selectedOutboundTCP != nil { + updated = true + } + g.selectedOutboundTCP = outbound + } + if outbound, exists := g.Select(N.NetworkUDP); outbound != nil && (g.selectedOutboundUDP == nil || (exists && outbound != g.selectedOutboundUDP)) { + if g.selectedOutboundUDP != nil { + updated = true + } + g.selectedOutboundUDP = outbound + } + if updated { + g.interruptGroup.Interrupt(g.interruptExternalConnections) + } +} diff --git a/protocol/http/inbound.go b/protocol/http/inbound.go new file mode 100644 index 00000000..e8a9a3da --- /dev/null +++ b/protocol/http/inbound.go @@ -0,0 +1,132 @@ +package http + +import ( + std_bufio "bufio" + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/http" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.HTTPMixedInboundOptions](registry, C.TypeHTTP, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + authenticator *auth.Authenticator + tlsConfig tls.ServerConfig +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeHTTP, tag), + router: uot.NewRouter(router, logger), + logger: logger, + authenticator: auth.NewAuthenticator(options.Users), + } + if options.TLS != nil { + tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ + Context: ctx, + Logger: logger, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: true, + }) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + SetSystemProxy: options.SetSystemProxy, + SystemProxySOCKS: false, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + h.tlsConfig, + ) +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if h.tlsConfig != nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return + } + conn = tlsConn + } + err := http.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } +} + +func (h *Inbound) newUserConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + user, loaded := auth.UserFromContext[string](ctx) + if !loaded { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } + metadata.User = user + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) streamUserPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + user, loaded := auth.UserFromContext[string](ctx) + if !loaded { + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } + metadata.User = user + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/http/outbound.go b/protocol/http/outbound.go new file mode 100644 index 00000000..48c4be6b --- /dev/null +++ b/protocol/http/outbound.go @@ -0,0 +1,65 @@ +package http + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + sHTTP "github.com/sagernet/sing/protocol/http" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.HTTPOutboundOptions](registry, C.TypeHTTP, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + client *sHTTP.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + detour, err := tls.NewDialerFromOptions(ctx, logger, outboundDialer, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeHTTP, tag, []string{N.NetworkTCP}, options.DialerOptions), + logger: logger, + client: sHTTP.NewClient(sHTTP.Options{ + Dialer: detour, + Server: options.ServerOptions.Build(), + Username: options.Username, + Password: options.Password, + Path: options.Path, + Headers: options.Headers.Build(), + }), + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialContext(ctx, network, destination) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} diff --git a/protocol/hysteria/inbound.go b/protocol/hysteria/inbound.go new file mode 100644 index 00000000..98d7cb81 --- /dev/null +++ b/protocol/hysteria/inbound.go @@ -0,0 +1,180 @@ +package hysteria + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic/hysteria" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.HysteriaInboundOptions](registry, C.TypeHysteria, NewInbound) +} + +type Inbound struct { + inbound.Adapter + router adapter.Router + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + service *hysteria.Service[int] + userNameList []string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaInboundOptions) (adapter.Inbound, error) { + options.UDPFragmentDefault = true + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeHysteria, tag), + router: router, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + tlsConfig: tlsConfig, + } + var sendBps, receiveBps uint64 + if options.Up.Value() > 0 { + sendBps = options.Up.Value() + } else { + sendBps = uint64(options.UpMbps) * hysteria.MbpsToBps + } + if options.Down.Value() > 0 { + receiveBps = options.Down.Value() + } else { + receiveBps = uint64(options.DownMbps) * hysteria.MbpsToBps + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + service, err := hysteria.NewService[int](hysteria.ServiceOptions{ + Context: ctx, + Logger: logger, + SendBPS: sendBps, + ReceiveBPS: receiveBps, + XPlusPassword: options.Obfs, + TLSConfig: tlsConfig, + UDPTimeout: udpTimeout, + Handler: inbound, + + // Legacy options + + ConnReceiveWindow: options.ReceiveWindowConn, + StreamReceiveWindow: options.ReceiveWindowClient, + MaxIncomingStreams: int64(options.MaxConnClient), + DisableMTUDiscovery: options.DisableMTUDiscovery, + }) + if err != nil { + return nil, err + } + userList := make([]int, 0, len(options.Users)) + userNameList := make([]string, 0, len(options.Users)) + userPasswordList := make([]string, 0, len(options.Users)) + for index, user := range options.Users { + userList = append(userList, index) + userNameList = append(userNameList, user.Name) + var password string + if user.AuthString != "" { + password = user.AuthString + } else { + password = string(user.Auth) + } + userPasswordList = append(userPasswordList, password) + } + service.UpdateUsers(userList, userPasswordList) + inbound.service = service + inbound.userNameList = userNameList + return inbound, nil +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.OriginDestination = h.listener.UDPAddr() + metadata.Source = source + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + userID, _ := auth.UserFromContext[int](ctx) + if userName := h.userNameList[userID]; userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.OriginDestination = h.listener.UDPAddr() + metadata.Source = source + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) + userID, _ := auth.UserFromContext[int](ctx) + if userName := h.userNameList[userID]; userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound packet connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return err + } + } + packetConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + return h.service.Start(packetConn) +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + h.tlsConfig, + common.PtrOrNil(h.service), + ) +} diff --git a/protocol/hysteria/outbound.go b/protocol/hysteria/outbound.go new file mode 100644 index 00000000..bcadd878 --- /dev/null +++ b/protocol/hysteria/outbound.go @@ -0,0 +1,126 @@ +package hysteria + +import ( + "context" + "net" + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/tuic" + "github.com/sagernet/sing-quic/hysteria" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.HysteriaOutboundOptions](registry, C.TypeHysteria, NewOutbound) +} + +var ( + _ adapter.Outbound = (*tuic.Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) +) + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + client *hysteria.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaOutboundOptions) (adapter.Outbound, error) { + options.UDPFragmentDefault = true + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + networkList := options.Network.Build() + var password string + if options.AuthString != "" { + password = options.AuthString + } else { + password = string(options.Auth) + } + var sendBps, receiveBps uint64 + if options.Up.Value() > 0 { + sendBps = options.Up.Value() + } else { + sendBps = uint64(options.UpMbps) * hysteria.MbpsToBps + } + if options.Down.Value() > 0 { + receiveBps = options.Down.Value() + } else { + receiveBps = uint64(options.DownMbps) * hysteria.MbpsToBps + } + client, err := hysteria.NewClient(hysteria.ClientOptions{ + Context: ctx, + Dialer: outboundDialer, + Logger: logger, + ServerAddress: options.ServerOptions.Build(), + ServerPorts: options.ServerPorts, + HopInterval: time.Duration(options.HopInterval), + SendBPS: sendBps, + ReceiveBPS: receiveBps, + XPlusPassword: options.Obfs, + Password: password, + TLSConfig: tlsConfig, + UDPDisabled: !common.Contains(networkList, N.NetworkUDP), + ConnReceiveWindow: options.ReceiveWindowConn, + StreamReceiveWindow: options.ReceiveWindow, + DisableMTUDiscovery: options.DisableMTUDiscovery, + }) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeHysteria, tag, networkList, options.DialerOptions), + logger: logger, + client: client, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialConn(ctx, destination) + case N.NetworkUDP: + conn, err := h.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(conn, destination), nil + default: + return nil, E.New("unsupported network: ", network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx, destination) +} + +func (h *Outbound) InterfaceUpdated() { + h.client.CloseWithError(E.New("network changed")) +} + +func (h *Outbound) Close() error { + return h.client.CloseWithError(os.ErrClosed) +} diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go new file mode 100644 index 00000000..5fe8848d --- /dev/null +++ b/protocol/hysteria2/inbound.go @@ -0,0 +1,214 @@ +package hysteria2 + +import ( + "context" + "net" + "net/http" + "net/http/httputil" + "net/url" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic/hysteria" + "github.com/sagernet/sing-quic/hysteria2" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.Hysteria2InboundOptions](registry, C.TypeHysteria2, NewInbound) +} + +type Inbound struct { + inbound.Adapter + router adapter.Router + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + service *hysteria2.Service[int] + userNameList []string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2InboundOptions) (adapter.Inbound, error) { + options.UDPFragmentDefault = true + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + var salamanderPassword string + if options.Obfs != nil { + if options.Obfs.Password == "" { + return nil, E.New("missing obfs password") + } + switch options.Obfs.Type { + case hysteria2.ObfsTypeSalamander: + salamanderPassword = options.Obfs.Password + default: + return nil, E.New("unknown obfs type: ", options.Obfs.Type) + } + } + var masqueradeHandler http.Handler + if options.Masquerade != nil && options.Masquerade.Type != "" { + switch options.Masquerade.Type { + case C.Hysterai2MasqueradeTypeFile: + masqueradeHandler = http.FileServer(http.Dir(options.Masquerade.FileOptions.Directory)) + case C.Hysterai2MasqueradeTypeProxy: + masqueradeURL, err := url.Parse(options.Masquerade.ProxyOptions.URL) + if err != nil { + return nil, E.Cause(err, "parse masquerade URL") + } + masqueradeHandler = &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(masqueradeURL) + if !options.Masquerade.ProxyOptions.RewriteHost { + r.Out.Host = r.In.Host + } + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + w.WriteHeader(http.StatusBadGateway) + }, + } + case C.Hysterai2MasqueradeTypeString: + masqueradeHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if options.Masquerade.StringOptions.StatusCode != 0 { + w.WriteHeader(options.Masquerade.StringOptions.StatusCode) + } + for key, values := range options.Masquerade.StringOptions.Headers { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.Write([]byte(options.Masquerade.StringOptions.Content)) + }) + default: + return nil, E.New("unknown masquerade type: ", options.Masquerade.Type) + } + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeHysteria2, tag), + router: router, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + tlsConfig: tlsConfig, + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + service, err := hysteria2.NewService[int](hysteria2.ServiceOptions{ + Context: ctx, + Logger: logger, + BrutalDebug: options.BrutalDebug, + SendBPS: uint64(options.UpMbps * hysteria.MbpsToBps), + ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps), + SalamanderPassword: salamanderPassword, + TLSConfig: tlsConfig, + IgnoreClientBandwidth: options.IgnoreClientBandwidth, + UDPTimeout: udpTimeout, + Handler: inbound, + MasqueradeHandler: masqueradeHandler, + BBRProfile: options.BBRProfile, + }) + if err != nil { + return nil, err + } + userList := make([]int, 0, len(options.Users)) + userNameList := make([]string, 0, len(options.Users)) + userPasswordList := make([]string, 0, len(options.Users)) + for index, user := range options.Users { + userList = append(userList, index) + userNameList = append(userNameList, user.Name) + userPasswordList = append(userPasswordList, user.Password) + } + service.UpdateUsers(userList, userPasswordList) + inbound.service = service + inbound.userNameList = userNameList + return inbound, nil +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.OriginDestination = h.listener.UDPAddr() + metadata.Source = source + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + userID, _ := auth.UserFromContext[int](ctx) + if userName := h.userNameList[userID]; userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.OriginDestination = h.listener.UDPAddr() + metadata.Source = source + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) + userID, _ := auth.UserFromContext[int](ctx) + if userName := h.userNameList[userID]; userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound packet connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return err + } + } + packetConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + return h.service.Start(packetConn) +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + h.tlsConfig, + common.PtrOrNil(h.service), + ) +} diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go new file mode 100644 index 00000000..4a0c9f24 --- /dev/null +++ b/protocol/hysteria2/outbound.go @@ -0,0 +1,122 @@ +package hysteria2 + +import ( + "context" + "net" + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/tuic" + "github.com/sagernet/sing-quic/hysteria" + "github.com/sagernet/sing-quic/hysteria2" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.Hysteria2OutboundOptions](registry, C.TypeHysteria2, NewOutbound) +} + +var ( + _ adapter.Outbound = (*tuic.Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) +) + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + client *hysteria2.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2OutboundOptions) (adapter.Outbound, error) { + options.UDPFragmentDefault = true + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + var salamanderPassword string + if options.Obfs != nil { + if options.Obfs.Password == "" { + return nil, E.New("missing obfs password") + } + switch options.Obfs.Type { + case hysteria2.ObfsTypeSalamander: + salamanderPassword = options.Obfs.Password + default: + return nil, E.New("unknown obfs type: ", options.Obfs.Type) + } + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + networkList := options.Network.Build() + client, err := hysteria2.NewClient(hysteria2.ClientOptions{ + Context: ctx, + Dialer: outboundDialer, + Logger: logger, + BrutalDebug: options.BrutalDebug, + ServerAddress: options.ServerOptions.Build(), + ServerPorts: options.ServerPorts, + HopInterval: time.Duration(options.HopInterval), + HopIntervalMax: time.Duration(options.HopIntervalMax), + SendBPS: uint64(options.UpMbps * hysteria.MbpsToBps), + ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps), + SalamanderPassword: salamanderPassword, + Password: options.Password, + TLSConfig: tlsConfig, + UDPDisabled: !common.Contains(networkList, N.NetworkUDP), + BBRProfile: options.BBRProfile, + }) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeHysteria2, tag, networkList, options.DialerOptions), + logger: logger, + client: client, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialConn(ctx, destination) + case N.NetworkUDP: + conn, err := h.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(conn, destination), nil + default: + return nil, E.New("unsupported network: ", network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx) +} + +func (h *Outbound) InterfaceUpdated() { + h.client.CloseWithError(E.New("network changed")) +} + +func (h *Outbound) Close() error { + return h.client.CloseWithError(os.ErrClosed) +} diff --git a/protocol/mixed/inbound.go b/protocol/mixed/inbound.go new file mode 100644 index 00000000..64c3edb5 --- /dev/null +++ b/protocol/mixed/inbound.go @@ -0,0 +1,168 @@ +package mixed + +import ( + std_bufio "bufio" + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/http" + "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/protocol/socks/socks4" + "github.com/sagernet/sing/protocol/socks/socks5" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.HTTPMixedInboundOptions](registry, C.TypeMixed, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + authenticator *auth.Authenticator + tlsConfig tls.ServerConfig + udpTimeout time.Duration +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) { + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeMixed, tag), + router: uot.NewRouter(router, logger), + logger: logger, + authenticator: auth.NewAuthenticator(options.Users), + udpTimeout: udpTimeout, + } + if options.TLS != nil { + tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ + Context: ctx, + Logger: logger, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: true, + }) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + SetSystemProxy: options.SetSystemProxy, + SystemProxySOCKS: true, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + h.tlsConfig, + ) +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := h.newConnection(ctx, conn, metadata, onClose) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil { + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + if h.tlsConfig != nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + return E.Cause(err, "TLS handshake") + } + conn = tlsConn + } + reader := std_bufio.NewReader(conn) + headerBytes, err := reader.Peek(1) + if err != nil { + return E.Cause(err, "peek first byte") + } + switch headerBytes[0] { + case socks4.Version, socks5.Version: + return socks.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose) + default: + return http.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose) + } +} + +func (h *Inbound) newUserConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + user, loaded := auth.UserFromContext[string](ctx) + if !loaded { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } + metadata.User = user + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) streamUserPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + user, loaded := auth.UserFromContext[string](ctx) + if !loaded { + if !metadata.Destination.IsValid() { + h.logger.InfoContext(ctx, "inbound packet connection") + } else { + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } + metadata.User = user + if !metadata.Destination.IsValid() { + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection") + } else { + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go new file mode 100644 index 00000000..5613f196 --- /dev/null +++ b/protocol/naive/inbound.go @@ -0,0 +1,251 @@ +package naive + +import ( + "context" + "errors" + "io" + "net" + "net/http" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + sHttp "github.com/sagernet/sing/protocol/http" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +var ( + ConfigureHTTP3ListenerFunc func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.NaiveInboundOptions) (io.Closer, error) + WrapError func(error) error +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.NaiveInboundOptions](registry, C.TypeNaive, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + options option.NaiveInboundOptions + listener *listener.Listener + network []string + networkIsDefault bool + authenticator *auth.Authenticator + tlsConfig tls.ServerConfig + httpServer *http.Server + h3Server io.Closer +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeNaive, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + networkIsDefault: options.Network == "", + network: options.Network.Build(), + authenticator: auth.NewAuthenticator(options.Users), + } + if common.Contains(inbound.network, N.NetworkUDP) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, E.New("TLS is required for QUIC server") + } + } + if len(options.Users) == 0 { + return nil, E.New("missing users") + } + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + return inbound, nil +} + +func (n *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if n.tlsConfig != nil { + err := n.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + if common.Contains(n.network, N.NetworkTCP) { + tcpListener, err := n.listener.ListenTCP() + if err != nil { + return err + } + n.httpServer = &http.Server{ + Handler: h2c.NewHandler(n, &http2.Server{}), + BaseContext: func(listener net.Listener) context.Context { + return n.ctx + }, + } + go func() { + listener := net.Listener(tcpListener) + if n.tlsConfig != nil { + if len(n.tlsConfig.NextProtos()) == 0 { + n.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"}) + } else if !common.Contains(n.tlsConfig.NextProtos(), http2.NextProtoTLS) { + n.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, n.tlsConfig.NextProtos()...)) + } + listener = aTLS.NewListener(tcpListener, n.tlsConfig) + } + sErr := n.httpServer.Serve(listener) + if sErr != nil && !errors.Is(sErr, http.ErrServerClosed) { + n.logger.Error("http server serve error: ", sErr) + } + }() + } + + if common.Contains(n.network, N.NetworkUDP) { + http3Server, err := ConfigureHTTP3ListenerFunc(n.ctx, n.logger, n.listener, n, n.tlsConfig, n.options) + if err == nil { + n.h3Server = http3Server + } else if len(n.network) > 1 { + n.logger.Warn(E.Cause(err, "naive http3 disabled")) + } else { + return err + } + } + + return nil +} + +func (n *Inbound) Close() error { + return common.Close( + &n.listener, + common.PtrOrNil(n.httpServer), + n.h3Server, + n.tlsConfig, + ) +} + +func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + ctx := log.ContextWithNewID(request.Context()) + if request.Method != "CONNECT" { + rejectHTTP(writer, http.StatusBadRequest) + n.badRequest(ctx, request, E.New("not CONNECT request")) + return + } else if request.Header.Get("Padding") == "" { + rejectHTTP(writer, http.StatusBadRequest) + n.badRequest(ctx, request, E.New("missing naive padding")) + return + } + userName, password, authOk := sHttp.ParseBasicAuth(request.Header.Get("Proxy-Authorization")) + if authOk { + authOk = n.authenticator.Verify(userName, password) + } + if !authOk { + rejectHTTP(writer, http.StatusProxyAuthRequired) + n.badRequest(ctx, request, E.New("authorization failed")) + return + } + writer.Header().Set("Padding", generatePaddingHeader()) + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + + hostPort := request.Header.Get("-connect-authority") + if hostPort == "" { + hostPort = request.URL.Host + if hostPort == "" { + hostPort = request.Host + } + } + source := sHttp.SourceAddress(request) + destination := M.ParseSocksaddr(hostPort).Unwrap() + + if hijacker, isHijacker := writer.(http.Hijacker); isHijacker { + conn, _, err := hijacker.Hijack() + if err != nil { + n.badRequest(ctx, request, E.New("hijack failed")) + return + } + n.newConnection(ctx, false, &naiveConn{Conn: conn}, userName, source, destination) + } else { + n.newConnection(ctx, true, &naiveH2Conn{ + reader: request.Body, + writer: writer, + flusher: writer.(http.Flusher), + remoteAddress: source, + }, userName, source, destination) + } +} + +func (n *Inbound) newConnection(ctx context.Context, waitForClose bool, conn net.Conn, userName string, source M.Socksaddr, destination M.Socksaddr) { + if userName != "" { + n.logger.InfoContext(ctx, "[", userName, "] inbound connection from ", source) + n.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", destination) + } else { + n.logger.InfoContext(ctx, "inbound connection from ", source) + n.logger.InfoContext(ctx, "inbound connection to ", destination) + } + var metadata adapter.InboundContext + metadata.Inbound = n.Tag() + metadata.InboundType = n.Type() + //nolint:staticcheck + metadata.InboundDetour = n.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.Source = source + metadata.Destination = destination + metadata.OriginDestination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap() + metadata.User = userName + if !waitForClose { + n.router.RouteConnectionEx(ctx, conn, metadata, nil) + } else { + done := make(chan struct{}) + wrapper := v2rayhttp.NewHTTP2Wrapper(conn) + n.router.RouteConnectionEx(ctx, conn, metadata, N.OnceClose(func(it error) { + close(done) + })) + <-done + wrapper.CloseWrapper() + } +} + +func (n *Inbound) badRequest(ctx context.Context, request *http.Request, err error) { + n.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", request.RemoteAddr)) +} + +func rejectHTTP(writer http.ResponseWriter, statusCode int) { + hijacker, ok := writer.(http.Hijacker) + if !ok { + writer.WriteHeader(statusCode) + return + } + conn, _, err := hijacker.Hijack() + if err != nil { + writer.WriteHeader(statusCode) + return + } + if tcpConn, isTCP := common.Cast[*net.TCPConn](conn); isTCP { + tcpConn.SetLinger(0) + } + conn.Close() +} diff --git a/protocol/naive/inbound_conn.go b/protocol/naive/inbound_conn.go new file mode 100644 index 00000000..8cc3ded2 --- /dev/null +++ b/protocol/naive/inbound_conn.go @@ -0,0 +1,257 @@ +package naive + +import ( + "encoding/binary" + "io" + "math/rand" + "net" + "net/http" + "os" + "time" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/baderror" + "github.com/sagernet/sing/common/buf" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/rw" +) + +const paddingCount = 8 + +func generatePaddingHeader() string { + paddingLen := rand.Intn(32) + 30 + padding := make([]byte, paddingLen) + bits := rand.Uint64() + for i := 0; i < 16; i++ { + padding[i] = "!#$()+<>?@[]^`{}"[bits&15] + bits >>= 4 + } + for i := 16; i < paddingLen; i++ { + padding[i] = '~' + } + return string(padding) +} + +type paddingConn struct { + readPadding int + writePadding int + readRemaining int + paddingRemaining int +} + +func (p *paddingConn) readWithPadding(reader io.Reader, buffer []byte) (n int, err error) { + if p.readRemaining > 0 { + if len(buffer) > p.readRemaining { + buffer = buffer[:p.readRemaining] + } + n, err = reader.Read(buffer) + if err != nil { + return + } + p.readRemaining -= n + return + } + if p.paddingRemaining > 0 { + err = rw.SkipN(reader, p.paddingRemaining) + if err != nil { + return + } + p.paddingRemaining = 0 + } + if p.readPadding < paddingCount { + var paddingHeader []byte + if len(buffer) >= 3 { + paddingHeader = buffer[:3] + } else { + paddingHeader = make([]byte, 3) + } + _, err = io.ReadFull(reader, paddingHeader) + if err != nil { + return + } + originalDataSize := int(binary.BigEndian.Uint16(paddingHeader[:2])) + paddingSize := int(paddingHeader[2]) + if len(buffer) > originalDataSize { + buffer = buffer[:originalDataSize] + } + n, err = reader.Read(buffer) + if err != nil { + return + } + p.readPadding++ + p.readRemaining = originalDataSize - n + p.paddingRemaining = paddingSize + return + } + return reader.Read(buffer) +} + +func (p *paddingConn) writeWithPadding(writer io.Writer, data []byte) (n int, err error) { + if p.writePadding < paddingCount { + paddingSize := rand.Intn(256) + buffer := buf.NewSize(3 + len(data) + paddingSize) + defer buffer.Release() + header := buffer.Extend(3) + binary.BigEndian.PutUint16(header, uint16(len(data))) + header[2] = byte(paddingSize) + common.Must1(buffer.Write(data)) + common.Must(buffer.WriteZeroN(paddingSize)) + _, err = writer.Write(buffer.Bytes()) + if err == nil { + n = len(data) + } + p.writePadding++ + return + } + return writer.Write(data) +} + +func (p *paddingConn) writeBufferWithPadding(writer io.Writer, buffer *buf.Buffer) error { + if p.writePadding < paddingCount { + bufferLen := buffer.Len() + if bufferLen > 65535 { + _, err := p.writeChunked(writer, buffer.Bytes()) + return err + } + paddingSize := rand.Intn(256) + header := buffer.ExtendHeader(3) + binary.BigEndian.PutUint16(header, uint16(bufferLen)) + header[2] = byte(paddingSize) + common.Must(buffer.WriteZeroN(paddingSize)) + p.writePadding++ + } + return common.Error(writer.Write(buffer.Bytes())) +} + +func (p *paddingConn) writeChunked(writer io.Writer, data []byte) (n int, err error) { + for len(data) > 0 { + var chunk []byte + if len(data) > 65535 { + chunk = data[:65535] + data = data[65535:] + } else { + chunk = data + data = nil + } + var written int + written, err = p.writeWithPadding(writer, chunk) + n += written + if err != nil { + return + } + } + return +} + +func (p *paddingConn) frontHeadroom() int { + if p.writePadding < paddingCount { + return 3 + } + return 0 +} + +func (p *paddingConn) rearHeadroom() int { + if p.writePadding < paddingCount { + return 255 + } + return 0 +} + +func (p *paddingConn) writerMTU() int { + if p.writePadding < paddingCount { + return 65535 + } + return 0 +} + +func (p *paddingConn) readerReplaceable() bool { + return p.readPadding == paddingCount +} + +func (p *paddingConn) writerReplaceable() bool { + return p.writePadding == paddingCount +} + +type naiveConn struct { + net.Conn + paddingConn +} + +func (c *naiveConn) Read(p []byte) (n int, err error) { + n, err = c.readWithPadding(c.Conn, p) + return n, wrapError(err) +} + +func (c *naiveConn) Write(p []byte) (n int, err error) { + n, err = c.writeChunked(c.Conn, p) + return n, wrapError(err) +} + +func (c *naiveConn) WriteBuffer(buffer *buf.Buffer) error { + defer buffer.Release() + err := c.writeBufferWithPadding(c.Conn, buffer) + return wrapError(err) +} + +func (c *naiveConn) FrontHeadroom() int { return c.frontHeadroom() } +func (c *naiveConn) RearHeadroom() int { return c.rearHeadroom() } +func (c *naiveConn) WriterMTU() int { return c.writerMTU() } +func (c *naiveConn) Upstream() any { return c.Conn } +func (c *naiveConn) ReaderReplaceable() bool { return c.readerReplaceable() } +func (c *naiveConn) WriterReplaceable() bool { return c.writerReplaceable() } + +type naiveH2Conn struct { + reader io.Reader + writer io.Writer + flusher http.Flusher + remoteAddress net.Addr + paddingConn +} + +func (c *naiveH2Conn) Read(p []byte) (n int, err error) { + n, err = c.readWithPadding(c.reader, p) + return n, wrapError(err) +} + +func (c *naiveH2Conn) Write(p []byte) (n int, err error) { + n, err = c.writeChunked(c.writer, p) + if err == nil { + c.flusher.Flush() + } + return n, wrapError(err) +} + +func (c *naiveH2Conn) WriteBuffer(buffer *buf.Buffer) error { + defer buffer.Release() + err := c.writeBufferWithPadding(c.writer, buffer) + if err == nil { + c.flusher.Flush() + } + return wrapError(err) +} + +func wrapError(err error) error { + err = baderror.WrapH2(err) + if WrapError != nil { + err = WrapError(err) + } + return err +} + +func (c *naiveH2Conn) Close() error { + return common.Close(c.reader, c.writer) +} + +func (c *naiveH2Conn) LocalAddr() net.Addr { return M.Socksaddr{} } +func (c *naiveH2Conn) RemoteAddr() net.Addr { return c.remoteAddress } +func (c *naiveH2Conn) SetDeadline(t time.Time) error { return os.ErrInvalid } +func (c *naiveH2Conn) SetReadDeadline(t time.Time) error { return os.ErrInvalid } +func (c *naiveH2Conn) SetWriteDeadline(t time.Time) error { return os.ErrInvalid } +func (c *naiveH2Conn) NeedAdditionalReadDeadline() bool { return true } +func (c *naiveH2Conn) UpstreamReader() any { return c.reader } +func (c *naiveH2Conn) UpstreamWriter() any { return c.writer } +func (c *naiveH2Conn) FrontHeadroom() int { return c.frontHeadroom() } +func (c *naiveH2Conn) RearHeadroom() int { return c.rearHeadroom() } +func (c *naiveH2Conn) WriterMTU() int { return c.writerMTU() } +func (c *naiveH2Conn) ReaderReplaceable() bool { return c.readerReplaceable() } +func (c *naiveH2Conn) WriterReplaceable() bool { return c.writerReplaceable() } diff --git a/protocol/naive/outbound.go b/protocol/naive/outbound.go new file mode 100644 index 00000000..8249a1fe --- /dev/null +++ b/protocol/naive/outbound.go @@ -0,0 +1,275 @@ +//go:build with_naive_outbound + +package naive + +import ( + "context" + "encoding/pem" + "net" + "os" + "strings" + + "github.com/sagernet/cronet-go" + _ "github.com/sagernet/cronet-go/all" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.NaiveOutboundOptions](registry, C.TypeNaive, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + client *cronet.NaiveClient + uotClient *uot.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveOutboundOptions) (adapter.Outbound, error) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + if options.TLS.DisableSNI { + return nil, E.New("disable_sni is not supported on naive outbound") + } + if options.TLS.Insecure { + return nil, E.New("insecure is not supported on naive outbound") + } + if len(options.TLS.ALPN) > 0 { + return nil, E.New("alpn is not supported on naive outbound") + } + if options.TLS.MinVersion != "" { + return nil, E.New("min_version is not supported on naive outbound") + } + if options.TLS.MaxVersion != "" { + return nil, E.New("max_version is not supported on naive outbound") + } + if len(options.TLS.CipherSuites) > 0 { + return nil, E.New("cipher_suites is not supported on naive outbound") + } + if len(options.TLS.CurvePreferences) > 0 { + return nil, E.New("curve_preferences is not supported on naive outbound") + } + if len(options.TLS.ClientCertificate) > 0 || options.TLS.ClientCertificatePath != "" { + return nil, E.New("client_certificate is not supported on naive outbound") + } + if len(options.TLS.ClientKey) > 0 || options.TLS.ClientKeyPath != "" { + return nil, E.New("client_key is not supported on naive outbound") + } + if options.TLS.Fragment || options.TLS.RecordFragment { + return nil, E.New("fragment is not supported on naive outbound") + } + if options.TLS.KernelTx || options.TLS.KernelRx { + return nil, E.New("kernel TLS is not supported on naive outbound") + } + if options.TLS.UTLS != nil && options.TLS.UTLS.Enabled { + return nil, E.New("uTLS is not supported on naive outbound") + } + if options.TLS.Reality != nil && options.TLS.Reality.Enabled { + return nil, E.New("reality is not supported on naive outbound") + } + + serverAddress := options.ServerOptions.Build() + + var serverName string + if options.TLS.ServerName != "" { + serverName = options.TLS.ServerName + } else { + serverName = serverAddress.AddrString() + } + + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: true, + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + + var trustedRootCertificates string + if len(options.TLS.Certificate) > 0 { + trustedRootCertificates = strings.Join(options.TLS.Certificate, "\n") + } else if options.TLS.CertificatePath != "" { + content, err := os.ReadFile(options.TLS.CertificatePath) + if err != nil { + return nil, E.Cause(err, "read certificate") + } + trustedRootCertificates = string(content) + } + + extraHeaders := make(map[string]string) + for key, values := range options.ExtraHeaders.Build() { + if len(values) > 0 { + extraHeaders[key] = values[0] + } + } + + dnsRouter := service.FromContext[adapter.DNSRouter](ctx) + var dnsResolver cronet.DNSResolverFunc + if dnsRouter != nil { + dnsResolver = func(dnsContext context.Context, request *mDNS.Msg) *mDNS.Msg { + response, err := dnsRouter.Exchange(dnsContext, request, outboundDialer.(dialer.ResolveDialer).QueryOptions()) + if err != nil { + logger.Error("DNS exchange failed: ", err) + return dns.FixedResponseStatus(request, mDNS.RcodeServerFailure) + } + return response + } + } + + var echEnabled bool + var echConfigList []byte + var echQueryServerName string + if options.TLS.ECH != nil && options.TLS.ECH.Enabled { + echEnabled = true + echQueryServerName = options.TLS.ECH.QueryServerName + var echConfig []byte + if len(options.TLS.ECH.Config) > 0 { + echConfig = []byte(strings.Join(options.TLS.ECH.Config, "\n")) + } else if options.TLS.ECH.ConfigPath != "" { + content, err := os.ReadFile(options.TLS.ECH.ConfigPath) + if err != nil { + return nil, E.Cause(err, "read ECH config") + } + echConfig = content + } + if len(echConfig) > 0 { + block, rest := pem.Decode(echConfig) + if block == nil || block.Type != "ECH CONFIGS" || len(rest) > 0 { + return nil, E.New("invalid ECH configs pem") + } + echConfigList = block.Bytes + } + } + var quicCongestionControl cronet.QUICCongestionControl + switch options.QUICCongestionControl { + case "": + quicCongestionControl = cronet.QUICCongestionControlDefault + case "bbr": + quicCongestionControl = cronet.QUICCongestionControlBBR + case "bbr2": + quicCongestionControl = cronet.QUICCongestionControlBBRv2 + case "cubic": + quicCongestionControl = cronet.QUICCongestionControlCubic + case "reno": + quicCongestionControl = cronet.QUICCongestionControlReno + default: + return nil, E.New("unknown quic congestion control: ", options.QUICCongestionControl) + } + client, err := cronet.NewNaiveClient(cronet.NaiveClientOptions{ + Context: ctx, + Logger: logger, + ServerAddress: serverAddress, + ServerName: serverName, + Username: options.Username, + Password: options.Password, + InsecureConcurrency: options.InsecureConcurrency, + ExtraHeaders: extraHeaders, + TrustedRootCertificates: trustedRootCertificates, + Dialer: outboundDialer, + DNSResolver: dnsResolver, + ECHEnabled: echEnabled, + ECHConfigList: echConfigList, + ECHQueryServerName: echQueryServerName, + QUIC: options.QUIC, + QUICCongestionControl: quicCongestionControl, + }) + if err != nil { + return nil, err + } + var uotClient *uot.Client + uotOptions := common.PtrValueOrDefault(options.UDPOverTCP) + if uotOptions.Enabled { + uotClient = &uot.Client{ + Dialer: &naiveDialer{client}, + Version: uotOptions.Version, + } + } + var networks []string + if uotClient != nil { + networks = []string{N.NetworkTCP, N.NetworkUDP} + } else { + networks = []string{N.NetworkTCP} + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeNaive, tag, networks, options.DialerOptions), + ctx: ctx, + logger: logger, + client: client, + uotClient: uotClient, + }, nil +} + +func (h *Outbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := h.client.Start() + if err != nil { + return err + } + h.logger.Info("NaiveProxy started, version: ", h.client.Engine().Version()) + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialEarly(ctx, destination) + case N.NetworkUDP: + if h.uotClient == nil { + return nil, E.New("UDP is not supported unless UDP over TCP is enabled") + } + h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + return h.uotClient.DialContext(ctx, network, destination) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if h.uotClient == nil { + return nil, E.New("UDP is not supported unless UDP over TCP is enabled") + } + return h.uotClient.ListenPacket(ctx, destination) +} + +func (h *Outbound) InterfaceUpdated() { + h.client.Engine().CloseAllConnections() +} + +func (h *Outbound) Close() error { + return h.client.Close() +} + +func (h *Outbound) Client() *cronet.NaiveClient { + return h.client +} + +type naiveDialer struct { + *cronet.NaiveClient +} + +func (d *naiveDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return d.NaiveClient.DialEarly(ctx, destination) +} diff --git a/protocol/naive/quic/inbound_init.go b/protocol/naive/quic/inbound_init.go new file mode 100644 index 00000000..1f868267 --- /dev/null +++ b/protocol/naive/quic/inbound_init.go @@ -0,0 +1,128 @@ +package quic + +import ( + "context" + "io" + "net/http" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/congestion" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-quic" + "github.com/sagernet/sing-quic/congestion_bbr1" + "github.com/sagernet/sing-quic/congestion_bbr2" + congestion_meta1 "github.com/sagernet/sing-quic/congestion_meta1" + congestion_meta2 "github.com/sagernet/sing-quic/congestion_meta2" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/ntp" +) + +func init() { + naive.ConfigureHTTP3ListenerFunc = func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.NaiveInboundOptions) (io.Closer, error) { + err := qtls.ConfigureHTTP3(tlsConfig) + if err != nil { + return nil, err + } + + udpConn, err := listener.ListenUDP() + if err != nil { + return nil, err + } + + var congestionControl func(conn *quic.Conn) congestion.CongestionControl + timeFunc := ntp.TimeFuncFromContext(ctx) + if timeFunc == nil { + timeFunc = time.Now + } + switch options.QUICCongestionControl { + case "", "bbr": + congestionControl = func(conn *quic.Conn) congestion.CongestionControl { + return congestion_meta2.NewBbrSender( + congestion_meta2.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + congestion.ByteCount(congestion_meta1.InitialCongestionWindow), + ) + } + case "bbr_standard": + congestionControl = func(conn *quic.Conn) congestion.CongestionControl { + return congestion_bbr1.NewBbrSender( + congestion_bbr1.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + congestion_bbr1.InitialCongestionWindowPackets, + congestion_bbr1.MaxCongestionWindowPackets, + ) + } + case "bbr2": + congestionControl = func(conn *quic.Conn) congestion.CongestionControl { + return congestion_bbr2.NewBBR2Sender( + congestion_bbr2.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + 0, + false, + ) + } + case "bbr2_variant": + congestionControl = func(conn *quic.Conn) congestion.CongestionControl { + return congestion_bbr2.NewBBR2Sender( + congestion_bbr2.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + 32*congestion.ByteCount(conn.Config().InitialPacketSize), + true, + ) + } + case "cubic": + congestionControl = func(conn *quic.Conn) congestion.CongestionControl { + return congestion_meta1.NewCubicSender( + congestion_meta1.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + false, + ) + } + case "reno": + congestionControl = func(conn *quic.Conn) congestion.CongestionControl { + return congestion_meta1.NewCubicSender( + congestion_meta1.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + true, + ) + } + default: + return nil, E.New("unknown quic congestion control: ", options.QUICCongestionControl) + } + + quicListener, err := qtls.ListenEarly(udpConn, tlsConfig, &quic.Config{ + MaxIncomingStreams: 1 << 60, + Allow0RTT: true, + }) + if err != nil { + udpConn.Close() + return nil, err + } + + h3Server := &http3.Server{ + Handler: handler, + ConnContext: func(ctx context.Context, conn *quic.Conn) context.Context { + conn.SetCongestionControl(congestionControl(conn)) + return log.ContextWithNewID(ctx) + }, + } + + go func() { + sErr := h3Server.ServeListener(quicListener) + udpConn.Close() + if sErr != nil && !E.IsClosedOrCanceled(sErr) { + logger.Error("http3 server closed: ", sErr) + } + }() + + return quicListener, nil + } + naive.WrapError = qtls.WrapError +} diff --git a/protocol/redirect/redirect.go b/protocol/redirect/redirect.go new file mode 100644 index 00000000..e04db8c4 --- /dev/null +++ b/protocol/redirect/redirect.go @@ -0,0 +1,68 @@ +package redirect + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/redir" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterRedirect(registry *inbound.Registry) { + inbound.Register[option.RedirectInboundOptions](registry, C.TypeRedirect, NewRedirect) +} + +type Redirect struct { + inbound.Adapter + router adapter.Router + logger log.ContextLogger + listener *listener.Listener +} + +func NewRedirect(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.RedirectInboundOptions) (adapter.Inbound, error) { + redirect := &Redirect{ + Adapter: inbound.NewAdapter(C.TypeRedirect, tag), + router: router, + logger: logger, + } + redirect.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: redirect, + }) + return redirect, nil +} + +func (h *Redirect) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *Redirect) Close() error { + return h.listener.Close() +} + +func (h *Redirect) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + destination, err := redir.GetOriginalDestination(conn) + if err != nil { + conn.Close() + h.logger.ErrorContext(ctx, "process connection from ", conn.RemoteAddr(), ": get redirect destination: ", err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + metadata.Destination = M.SocksaddrFromNetIP(destination) + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/redirect/tproxy.go b/protocol/redirect/tproxy.go new file mode 100644 index 00000000..f0b82bb1 --- /dev/null +++ b/protocol/redirect/tproxy.go @@ -0,0 +1,150 @@ +package redirect + +import ( + "context" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/redir" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/udpnat2" +) + +func RegisterTProxy(registry *inbound.Registry) { + inbound.Register[option.TProxyInboundOptions](registry, C.TypeTProxy, NewTProxy) +} + +type TProxy struct { + inbound.Adapter + ctx context.Context + router adapter.Router + logger log.ContextLogger + listener *listener.Listener + udpNat *udpnat.Service +} + +func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TProxyInboundOptions) (adapter.Inbound, error) { + tproxy := &TProxy{ + Adapter: inbound.NewAdapter(C.TypeTProxy, tag), + ctx: ctx, + router: router, + logger: logger, + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + tproxy.udpNat = udpnat.New(tproxy, tproxy.preparePacketConnection, udpTimeout, false) + tproxy.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: options.Network.Build(), + Listen: options.ListenOptions, + ConnectionHandler: tproxy, + OOBPacketHandler: tproxy, + TProxy: true, + }) + return tproxy, nil +} + +func (t *TProxy) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return t.listener.Start() +} + +func (t *TProxy) Close() error { + return t.listener.Close() +} + +func (t *TProxy) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = t.Tag() + metadata.InboundType = t.Type() + metadata.Destination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap() + t.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + t.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (t *TProxy) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + t.logger.InfoContext(ctx, "inbound packet connection from ", source) + t.logger.InfoContext(ctx, "inbound packet connection to ", destination) + var metadata adapter.InboundContext + metadata.Inbound = t.Tag() + metadata.InboundType = t.Type() + metadata.Source = source + metadata.Destination = destination + metadata.OriginDestination = t.listener.UDPAddr() + t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (t *TProxy) NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr) { + destination, err := redir.GetOriginalDestinationFromOOB(oob) + if err != nil { + t.logger.Warn("process packet from ", source, ": get tproxy destination: ", err) + return + } + t.udpNat.NewPacket([][]byte{buffer.Bytes()}, source, M.SocksaddrFromNetIP(destination), nil) +} + +func (t *TProxy) preparePacketConnection(source M.Socksaddr, destination M.Socksaddr, userData any) (bool, context.Context, N.PacketWriter, N.CloseHandlerFunc) { + ctx := log.ContextWithNewID(t.ctx) + writer := &tproxyPacketWriter{ + ctx: ctx, + listener: t.listener, + source: source.AddrPort(), + destination: destination, + } + return true, ctx, writer, func(it error) { + common.Close(common.PtrOrNil(writer.conn)) + } +} + +type tproxyPacketWriter struct { + ctx context.Context + listener *listener.Listener + source netip.AddrPort + destination M.Socksaddr + conn *net.UDPConn +} + +func (w *tproxyPacketWriter) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + defer buffer.Release() + if w.listener.ListenOptions().NetNs == "" { + conn := w.conn + if w.destination == destination && conn != nil { + _, err := conn.WriteToUDPAddrPort(buffer.Bytes(), w.source) + if err != nil { + w.conn = nil + } + return err + } + } + var listenConfig net.ListenConfig + listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr()) + listenConfig.Control = control.Append(listenConfig.Control, redir.TProxyWriteBack()) + packetConn, err := w.listener.ListenPacket(listenConfig, w.ctx, "udp", destination.String()) + if err != nil { + return err + } + udpConn := packetConn.(*net.UDPConn) + if w.listener.ListenOptions().NetNs == "" && w.destination == destination { + w.conn = udpConn + } else { + defer udpConn.Close() + } + return common.Error(udpConn.WriteToUDPAddrPort(buffer.Bytes(), w.source)) +} diff --git a/protocol/shadowsocks/inbound.go b/protocol/shadowsocks/inbound.go new file mode 100644 index 00000000..52e2c524 --- /dev/null +++ b/protocol/shadowsocks/inbound.go @@ -0,0 +1,188 @@ +package shadowsocks + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks" + "github.com/sagernet/sing-shadowsocks/shadowaead" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.ShadowsocksInboundOptions](registry, C.TypeShadowsocks, NewInbound) +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (adapter.Inbound, error) { + if len(options.Users) > 0 && len(options.Destinations) > 0 { + return nil, E.New("users and destinations options must not be combined") + } else if options.Managed && (len(options.Users) > 0 || len(options.Destinations) > 0) { + return nil, E.New("users and destinations options are not supported in managed servers") + } + if len(options.Users) > 0 || options.Managed { + return newMultiInbound(ctx, router, logger, tag, options) + } else if len(options.Destinations) > 0 { + return newRelayInbound(ctx, router, logger, tag, options) + } else { + return newInbound(ctx, router, logger, tag, options) + } +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service shadowsocks.Service +} + +func newInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeShadowsocks, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + } + var err error + inbound.router, err = mux.NewRouterWithOptions(inbound.router, logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + switch { + case options.Method == shadowsocks.MethodNone: + inbound.service = shadowsocks.NewNoneService(int64(udpTimeout.Seconds()), adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound)) + case common.Contains(shadowaead.List, options.Method): + inbound.service, err = shadowaead.NewService(options.Method, nil, options.Password, int64(udpTimeout.Seconds()), adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound)) + case common.Contains(shadowaead_2022.List, options.Method): + inbound.service, err = shadowaead_2022.NewServiceWithPassword(options.Method, options.Password, int64(udpTimeout.Seconds()), adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound), ntp.TimeFuncFromContext(ctx)) + default: + err = E.New("unsupported method: ", options.Method) + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: options.Network.Build(), + Listen: options.ListenOptions, + ConnectionHandler: inbound, + PacketHandler: inbound, + ThreadUnsafePacketWriter: true, + }) + return inbound, err +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return h.listener.Close() +} + +//nolint:staticcheck +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata)) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil { + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +//nolint:staticcheck +func (h *Inbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) { + err := h.service.NewPacket(h.ctx, &stubPacketConn{h.listener.PacketWriter()}, buffer, M.Metadata{Source: source}) + if err != nil { + h.logger.Error(E.Cause(err, "process packet from ", source)) + } +} + +func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + return h.router.RouteConnection(ctx, conn, metadata) +} + +func (h *Inbound) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + ctx = log.ContextWithNewID(ctx) + h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + return h.router.RoutePacketConnection(ctx, conn, metadata) +} + +var _ N.PacketConn = (*stubPacketConn)(nil) + +type stubPacketConn struct { + N.PacketWriter +} + +func (c *stubPacketConn) ReadPacket(buffer *buf.Buffer) (destination M.Socksaddr, err error) { + panic("stub!") +} + +func (c *stubPacketConn) Close() error { + return nil +} + +func (c *stubPacketConn) LocalAddr() net.Addr { + panic("stub!") +} + +func (c *stubPacketConn) SetDeadline(t time.Time) error { + panic("stub!") +} + +func (c *stubPacketConn) SetReadDeadline(t time.Time) error { + panic("stub!") +} + +func (c *stubPacketConn) SetWriteDeadline(t time.Time) error { + panic("stub!") +} + +func (h *Inbound) NewError(ctx context.Context, err error) { + NewError(h.logger, ctx, err) +} + +// Deprecated: remove +func NewError(logger logger.ContextLogger, ctx context.Context, err error) { + common.Close(err) + if E.IsClosedOrCanceled(err) { + logger.DebugContext(ctx, "connection closed: ", err) + return + } + logger.ErrorContext(ctx, err) +} diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go new file mode 100644 index 00000000..7ff92646 --- /dev/null +++ b/protocol/shadowsocks/inbound_multi.go @@ -0,0 +1,212 @@ +package shadowsocks + +import ( + "context" + "net" + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks" + "github.com/sagernet/sing-shadowsocks/shadowaead" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" +) + +var ( + _ adapter.TCPInjectableInbound = (*MultiInbound)(nil) + _ adapter.ManagedSSMServer = (*MultiInbound)(nil) +) + +type MultiInbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service shadowsocks.MultiService[int] + users []option.ShadowsocksUser + tracker adapter.SSMTracker +} + +func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) { + inbound := &MultiInbound{ + Adapter: inbound.NewAdapter(C.TypeShadowsocks, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + } + var err error + inbound.router, err = mux.NewRouterWithOptions(inbound.router, logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + var service shadowsocks.MultiService[int] + if common.Contains(shadowaead_2022.List, options.Method) { + service, err = shadowaead_2022.NewMultiServiceWithPassword[int]( + options.Method, + options.Password, + int64(udpTimeout.Seconds()), + adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound), + ntp.TimeFuncFromContext(ctx), + ) + } else if common.Contains(shadowaead.List, options.Method) { + service, err = shadowaead.NewMultiService[int]( + options.Method, + int64(udpTimeout.Seconds()), + adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound), + ) + } else { + return nil, E.New("unsupported method: " + options.Method) + } + if err != nil { + return nil, err + } + if len(options.Users) > 0 { + err = service.UpdateUsersWithPasswords(common.MapIndexed(options.Users, func(index int, user option.ShadowsocksUser) int { + return index + }), common.Map(options.Users, func(user option.ShadowsocksUser) string { + return user.Password + })) + if err != nil { + return nil, err + } + } + inbound.service = service + inbound.users = options.Users + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: options.Network.Build(), + Listen: options.ListenOptions, + ConnectionHandler: inbound, + PacketHandler: inbound, + ThreadUnsafePacketWriter: true, + }) + return inbound, err +} + +func (h *MultiInbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *MultiInbound) Close() error { + return h.listener.Close() +} + +func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) { + h.tracker = tracker +} + +func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error { + err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int { + return index + }), uPSKs) + if err != nil { + return err + } + h.users = common.Map(users, func(user string) option.ShadowsocksUser { + return option.ShadowsocksUser{ + Name: user, + } + }) + return nil +} + +//nolint:staticcheck +func (h *MultiInbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata)) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil { + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +//nolint:staticcheck +func (h *MultiInbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) { + err := h.service.NewPacket(h.ctx, &stubPacketConn{h.listener.PacketWriter()}, buffer, M.Metadata{Source: source}) + if err != nil { + h.logger.Error(E.Cause(err, "process packet from ", source)) + } +} + +func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + return os.ErrInvalid + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + if h.tracker != nil { + conn = h.tracker.TrackConnection(conn, metadata) + } + return h.router.RouteConnection(ctx, conn, metadata) +} + +func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + return os.ErrInvalid + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + ctx = log.ContextWithNewID(ctx) + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection from ", metadata.Source) + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + if h.tracker != nil { + conn = h.tracker.TrackPacketConnection(conn, metadata) + } + return h.router.RoutePacketConnection(ctx, conn, metadata) +} + +//nolint:staticcheck +func (h *MultiInbound) NewError(ctx context.Context, err error) { + NewError(h.logger, ctx, err) +} diff --git a/protocol/shadowsocks/inbound_relay.go b/protocol/shadowsocks/inbound_relay.go new file mode 100644 index 00000000..d7d7bcff --- /dev/null +++ b/protocol/shadowsocks/inbound_relay.go @@ -0,0 +1,166 @@ +package shadowsocks + +import ( + "context" + "net" + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ adapter.TCPInjectableInbound = (*RelayInbound)(nil) + +type RelayInbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service *shadowaead_2022.RelayService[int] + destinations []option.ShadowsocksDestination +} + +func newRelayInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*RelayInbound, error) { + inbound := &RelayInbound{ + Adapter: inbound.NewAdapter(C.TypeShadowsocks, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + destinations: options.Destinations, + } + var err error + inbound.router, err = mux.NewRouterWithOptions(inbound.router, logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + service, err := shadowaead_2022.NewRelayServiceWithPassword[int]( + options.Method, + options.Password, + int64(udpTimeout.Seconds()), + adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound), + ) + if err != nil { + return nil, err + } + err = service.UpdateUsersWithPasswords(common.MapIndexed(options.Destinations, func(index int, user option.ShadowsocksDestination) int { + return index + }), common.Map(options.Destinations, func(user option.ShadowsocksDestination) string { + return user.Password + }), common.Map(options.Destinations, option.ShadowsocksDestination.Build)) + if err != nil { + return nil, err + } + inbound.service = service + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: options.Network.Build(), + Listen: options.ListenOptions, + ConnectionHandler: inbound, + PacketHandler: inbound, + ThreadUnsafePacketWriter: true, + }) + return inbound, err +} + +func (h *RelayInbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *RelayInbound) Close() error { + return h.listener.Close() +} + +//nolint:staticcheck +func (h *RelayInbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata)) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil { + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +//nolint:staticcheck +func (h *RelayInbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) { + err := h.service.NewPacket(h.ctx, &stubPacketConn{h.listener.PacketWriter()}, buffer, M.Metadata{Source: source}) + if err != nil { + h.logger.Error(E.Cause(err, "process packet from ", source)) + } +} + +func (h *RelayInbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + destinationIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + return os.ErrInvalid + } + destination := h.destinations[destinationIndex].Name + if destination == "" { + destination = F.ToString(destinationIndex) + } else { + metadata.User = destination + } + h.logger.InfoContext(ctx, "[", destination, "] inbound connection to ", metadata.Destination) + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + return h.router.RouteConnection(ctx, conn, metadata) +} + +func (h *RelayInbound) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + destinationIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + return os.ErrInvalid + } + destination := h.destinations[destinationIndex].Name + if destination == "" { + destination = F.ToString(destinationIndex) + } else { + metadata.User = destination + } + ctx = log.ContextWithNewID(ctx) + h.logger.InfoContext(ctx, "[", destination, "] inbound packet connection from ", metadata.Source) + h.logger.InfoContext(ctx, "[", destination, "] inbound packet connection to ", metadata.Destination) + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + return h.router.RoutePacketConnection(ctx, conn, metadata) +} + +//nolint:staticcheck +func (h *RelayInbound) NewError(ctx context.Context, err error) { + NewError(h.logger, ctx, err) +} diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go new file mode 100644 index 00000000..9b9d9252 --- /dev/null +++ b/protocol/shadowsocks/outbound.go @@ -0,0 +1,178 @@ +package shadowsocks + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/mux" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/sip003" + "github.com/sagernet/sing-shadowsocks2" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.ShadowsocksOutboundOptions](registry, C.TypeShadowsocks, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + dialer N.Dialer + method shadowsocks.Method + serverAddr M.Socksaddr + plugin sip003.Plugin + uotClient *uot.Client + multiplexDialer *mux.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksOutboundOptions) (adapter.Outbound, error) { + method, err := shadowsocks.CreateMethod(ctx, options.Method, shadowsocks.MethodOptions{ + Password: options.Password, + }) + if err != nil { + return nil, err + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeShadowsocks, tag, options.Network.Build(), options.DialerOptions), + logger: logger, + dialer: outboundDialer, + method: method, + serverAddr: options.ServerOptions.Build(), + } + if options.Plugin != "" { + outbound.plugin, err = sip003.CreatePlugin(ctx, options.Plugin, options.PluginOptions, router, outbound.dialer, outbound.serverAddr) + if err != nil { + return nil, err + } + } + uotOptions := common.PtrValueOrDefault(options.UDPOverTCP) + if !uotOptions.Enabled { + outbound.multiplexDialer, err = mux.NewClientWithOptions((*shadowsocksDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + } + if uotOptions.Enabled { + outbound.uotClient = &uot.Client{ + Dialer: (*shadowsocksDialer)(outbound), + Version: uotOptions.Version, + } + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + if h.multiplexDialer == nil { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + if h.uotClient != nil { + h.logger.InfoContext(ctx, "outbound UoT connect packet connection to ", destination) + return h.uotClient.DialContext(ctx, network, destination) + } else { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + } + return (*shadowsocksDialer)(h).DialContext(ctx, network, destination) + } else { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound multiplex connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + } + return h.multiplexDialer.DialContext(ctx, network, destination) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + if h.multiplexDialer == nil { + if h.uotClient != nil { + h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + return h.uotClient.ListenPacket(ctx, destination) + } else { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return (*shadowsocksDialer)(h).ListenPacket(ctx, destination) + } else { + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + return h.multiplexDialer.ListenPacket(ctx, destination) + } +} + +func (h *Outbound) InterfaceUpdated() { + if h.multiplexDialer != nil { + h.multiplexDialer.Reset() + } +} + +func (h *Outbound) Close() error { + return common.Close(common.PtrOrNil(h.multiplexDialer)) +} + +var _ N.Dialer = (*shadowsocksDialer)(nil) + +type shadowsocksDialer Outbound + +func (h *shadowsocksDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + switch N.NetworkName(network) { + case N.NetworkTCP: + var outConn net.Conn + var err error + if h.plugin != nil { + outConn, err = h.plugin.DialContext(ctx) + } else { + outConn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + } + if err != nil { + return nil, err + } + return h.method.DialEarlyConn(outConn, destination), nil + case N.NetworkUDP: + outConn, err := h.dialer.DialContext(ctx, N.NetworkUDP, h.serverAddr) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(h.method.DialPacketConn(outConn), destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *shadowsocksDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + outConn, err := h.dialer.DialContext(ctx, N.NetworkUDP, h.serverAddr) + if err != nil { + return nil, err + } + return h.method.DialPacketConn(outConn), nil +} diff --git a/protocol/shadowtls/inbound.go b/protocol/shadowtls/inbound.go new file mode 100644 index 00000000..17afa268 --- /dev/null +++ b/protocol/shadowtls/inbound.go @@ -0,0 +1,141 @@ +package shadowtls + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowtls" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.ShadowTLSInboundOptions](registry, C.TypeShadowTLS, NewInbound) +} + +type Inbound struct { + inbound.Adapter + router adapter.Router + logger logger.ContextLogger + listener *listener.Listener + service *shadowtls.Service +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowTLSInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeShadowTLS, tag), + router: router, + logger: logger, + } + + if options.Version == 0 { + options.Version = 1 + } + + var handshakeForServerName map[string]shadowtls.HandshakeConfig + if options.Version > 1 { + handshakeForServerName = make(map[string]shadowtls.HandshakeConfig) + if options.HandshakeForServerName != nil { + for _, entry := range options.HandshakeForServerName.Entries() { + handshakeDialer, err := dialer.New(ctx, entry.Value.DialerOptions, entry.Value.ServerIsDomain()) + if err != nil { + return nil, err + } + handshakeForServerName[entry.Key] = shadowtls.HandshakeConfig{ + Server: entry.Value.ServerOptions.Build(), + Dialer: handshakeDialer, + } + } + } + } + serverIsDomain := options.Handshake.ServerIsDomain() + if options.WildcardSNI != option.ShadowTLSWildcardSNIOff { + serverIsDomain = true + } + handshakeDialer, err := dialer.New(ctx, options.Handshake.DialerOptions, serverIsDomain) + if err != nil { + return nil, err + } + service, err := shadowtls.NewService(shadowtls.ServiceConfig{ + Version: options.Version, + Password: options.Password, + Users: common.Map(options.Users, func(it option.ShadowTLSUser) shadowtls.User { + return (shadowtls.User)(it) + }), + Handshake: shadowtls.HandshakeConfig{ + Server: options.Handshake.ServerOptions.Build(), + Dialer: handshakeDialer, + }, + HandshakeForServerName: handshakeForServerName, + StrictMode: options.StrictMode, + WildcardSNI: shadowtls.WildcardSNI(options.WildcardSNI), + Handler: (*inboundHandler)(inbound), + Logger: logger, + }) + if err != nil { + return nil, err + } + inbound.service = service + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return h.listener.Close() +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := h.service.NewConnection(adapter.WithContext(log.ContextWithNewID(ctx), &metadata), conn, metadata.Source, metadata.Destination, onClose) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil { + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +type inboundHandler Inbound + +func (h *inboundHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.Source = source + metadata.Destination = destination + if userName, _ := auth.UserFromContext[string](ctx); userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/shadowtls/outbound.go b/protocol/shadowtls/outbound.go new file mode 100644 index 00000000..41a4a601 --- /dev/null +++ b/protocol/shadowtls/outbound.go @@ -0,0 +1,104 @@ +package shadowtls + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowtls" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.ShadowTLSOutboundOptions](registry, C.TypeShadowTLS, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + client *shadowtls.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowTLSOutboundOptions) (adapter.Outbound, error) { + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeShadowTLS, tag, []string{N.NetworkTCP}, options.DialerOptions), + } + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + + if options.Version == 0 { + options.Version = 1 + } + + if options.Version == 1 { + options.TLS.MinVersion = "1.2" + options.TLS.MaxVersion = "1.2" + } + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + + var tlsHandshakeFunc shadowtls.TLSHandshakeFunc + switch options.Version { + case 1, 2: + tlsHandshakeFunc = func(ctx context.Context, conn net.Conn, _ shadowtls.TLSSessionIDGeneratorFunc) error { + return common.Error(tls.ClientHandshake(ctx, conn, tlsConfig)) + } + case 3: + if idConfig, loaded := tlsConfig.(tls.WithSessionIDGenerator); loaded { + tlsHandshakeFunc = func(ctx context.Context, conn net.Conn, sessionIDGenerator shadowtls.TLSSessionIDGeneratorFunc) error { + idConfig.SetSessionIDGenerator(sessionIDGenerator) + return common.Error(tls.ClientHandshake(ctx, conn, tlsConfig)) + } + } else { + stdTLSConfig, err := tlsConfig.STDConfig() + if err != nil { + return nil, err + } + tlsHandshakeFunc = shadowtls.DefaultTLSHandshakeFunc(options.Password, stdTLSConfig) + } + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + client, err := shadowtls.NewClient(shadowtls.ClientConfig{ + Version: options.Version, + Password: options.Password, + Server: options.ServerOptions.Build(), + Dialer: outboundDialer, + TLSHandshake: tlsHandshakeFunc, + Logger: logger, + }) + if err != nil { + return nil, err + } + outbound.client = client + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + switch N.NetworkName(network) { + case N.NetworkTCP: + return h.client.DialContext(ctx) + default: + return nil, os.ErrInvalid + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} diff --git a/protocol/socks/inbound.go b/protocol/socks/inbound.go new file mode 100644 index 00000000..68e0ef58 --- /dev/null +++ b/protocol/socks/inbound.go @@ -0,0 +1,119 @@ +package socks + +import ( + std_bufio "bufio" + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.SocksInboundOptions](registry, C.TypeSOCKS, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + authenticator *auth.Authenticator + udpTimeout time.Duration +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SocksInboundOptions) (adapter.Inbound, error) { + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeSOCKS, tag), + router: uot.NewRouter(router, logger), + logger: logger, + authenticator: auth.NewAuthenticator(options.Users), + udpTimeout: udpTimeout, + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return h.listener.Close() +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := socks.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil { + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +func (h *Inbound) newUserConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + user, loaded := auth.UserFromContext[string](ctx) + if !loaded { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } + metadata.User = user + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) streamUserPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + user, loaded := auth.UserFromContext[string](ctx) + if !loaded { + if !metadata.Destination.IsValid() { + h.logger.InfoContext(ctx, "inbound packet connection") + } else { + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } + metadata.User = user + if !metadata.Destination.IsValid() { + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection") + } else { + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/socks/outbound.go b/protocol/socks/outbound.go new file mode 100644 index 00000000..344c7988 --- /dev/null +++ b/protocol/socks/outbound.go @@ -0,0 +1,117 @@ +package socks + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" + "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.SOCKSOutboundOptions](registry, C.TypeSOCKS, NewOutbound) +} + +var _ adapter.Outbound = (*Outbound)(nil) + +type Outbound struct { + outbound.Adapter + dnsRouter adapter.DNSRouter + logger logger.ContextLogger + client *socks.Client + resolve bool + uotClient *uot.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SOCKSOutboundOptions) (adapter.Outbound, error) { + var version socks.Version + var err error + if options.Version != "" { + version, err = socks.ParseVersion(options.Version) + } else { + version = socks.Version5 + } + if err != nil { + return nil, err + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSOCKS, tag, options.Network.Build(), options.DialerOptions), + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + logger: logger, + client: socks.NewClient(outboundDialer, options.ServerOptions.Build(), version, options.Username, options.Password), + resolve: version == socks.Version4, + } + uotOptions := common.PtrValueOrDefault(options.UDPOverTCP) + if uotOptions.Enabled { + outbound.uotClient = &uot.Client{ + Dialer: outbound.client, + Version: uotOptions.Version, + } + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + if h.uotClient != nil { + h.logger.InfoContext(ctx, "outbound UoT connect packet connection to ", destination) + return h.uotClient.DialContext(ctx, network, destination) + } + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } + if h.resolve && destination.IsDomain() { + destinationAddresses, err := h.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, h.client, network, destination, destinationAddresses) + } + return h.client.DialContext(ctx, network, destination) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + if h.uotClient != nil { + h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + return h.uotClient.ListenPacket(ctx, destination) + } + if h.resolve && destination.IsDomain() { + destinationAddresses, err := h.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + packetConn, _, err := N.ListenSerial(ctx, h.client, destination, destinationAddresses) + if err != nil { + return nil, err + } + return packetConn, nil + } + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx, destination) +} diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go new file mode 100644 index 00000000..b2f96807 --- /dev/null +++ b/protocol/ssh/outbound.go @@ -0,0 +1,219 @@ +package ssh + +import ( + "bytes" + "context" + "encoding/base64" + "math/rand" + "net" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/crypto/ssh" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.SSHOutboundOptions](registry, C.TypeSSH, NewOutbound) +} + +var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + dialer N.Dialer + serverAddr M.Socksaddr + user string + hostKey []ssh.PublicKey + hostKeyAlgorithms []string + clientVersion string + authMethod []ssh.AuthMethod + clientAccess sync.Mutex + clientConn net.Conn + client *ssh.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSSH, tag, []string{N.NetworkTCP}, options.DialerOptions), + ctx: ctx, + logger: logger, + dialer: outboundDialer, + serverAddr: options.ServerOptions.Build(), + user: options.User, + hostKeyAlgorithms: options.HostKeyAlgorithms, + clientVersion: options.ClientVersion, + } + if outbound.serverAddr.Port == 0 { + outbound.serverAddr.Port = 22 + } + if outbound.user == "" { + outbound.user = "root" + } + if outbound.clientVersion == "" { + outbound.clientVersion = randomVersion() + } + if options.Password != "" { + outbound.authMethod = append(outbound.authMethod, ssh.Password(options.Password)) + } + if len(options.PrivateKey) > 0 || options.PrivateKeyPath != "" { + var privateKey []byte + if len(options.PrivateKey) > 0 { + privateKey = []byte(strings.Join(options.PrivateKey, "\n")) + } else { + var err error + privateKey, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath)) + if err != nil { + return nil, E.Cause(err, "read private key") + } + } + var signer ssh.Signer + var err error + if options.PrivateKeyPassphrase == "" { + signer, err = ssh.ParsePrivateKey(privateKey) + } else { + signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(options.PrivateKeyPassphrase)) + } + if err != nil { + return nil, E.Cause(err, "parse private key") + } + outbound.authMethod = append(outbound.authMethod, ssh.PublicKeys(signer)) + } + if len(options.HostKey) > 0 { + for _, hostKey := range options.HostKey { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(hostKey)) + if err != nil { + return nil, E.New("parse host key ", key) + } + outbound.hostKey = append(outbound.hostKey, key) + } + } + return outbound, nil +} + +func randomVersion() string { + version := "SSH-2.0-OpenSSH_" + if rand.Intn(2) == 0 { + version += "7." + strconv.Itoa(rand.Intn(10)) + } else { + version += "8." + strconv.Itoa(rand.Intn(9)) + } + return version +} + +func (s *Outbound) connect() (*ssh.Client, error) { + if s.client != nil { + return s.client, nil + } + + s.clientAccess.Lock() + defer s.clientAccess.Unlock() + + if s.client != nil { + return s.client, nil + } + + conn, err := s.dialer.DialContext(s.ctx, N.NetworkTCP, s.serverAddr) + if err != nil { + return nil, err + } + config := &ssh.ClientConfig{ + User: s.user, + Auth: s.authMethod, + ClientVersion: s.clientVersion, + HostKeyAlgorithms: s.hostKeyAlgorithms, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + if len(s.hostKey) == 0 { + return nil + } + serverKey := key.Marshal() + for _, hostKey := range s.hostKey { + if bytes.Equal(serverKey, hostKey.Marshal()) { + return nil + } + } + return E.New("host key mismatch, server send ", key.Type(), " ", base64.StdEncoding.EncodeToString(serverKey)) + }, + } + clientConn, chans, reqs, err := ssh.NewClientConn(conn, s.serverAddr.Addr.String(), config) + if err != nil { + conn.Close() + return nil, E.Cause(err, "connect to ssh server") + } + + client := ssh.NewClient(clientConn, chans, reqs) + + s.clientConn = conn + s.client = client + + go func() { + client.Wait() + conn.Close() + s.clientAccess.Lock() + s.client = nil + s.clientConn = nil + s.clientAccess.Unlock() + }() + + return client, nil +} + +func (s *Outbound) InterfaceUpdated() { + common.Close(s.clientConn) +} + +func (s *Outbound) Close() error { + return common.Close(s.clientConn) +} + +func (s *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + client, err := s.connect() + if err != nil { + return nil, err + } + conn, err := client.Dial(network, destination.String()) + if err != nil { + return nil, err + } + return &chanConnWrapper{Conn: conn}, nil +} + +func (s *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +type chanConnWrapper struct { + net.Conn +} + +func (c *chanConnWrapper) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *chanConnWrapper) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *chanConnWrapper) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} diff --git a/protocol/tailscale/certificate_provider.go b/protocol/tailscale/certificate_provider.go new file mode 100644 index 00000000..5ac18a30 --- /dev/null +++ b/protocol/tailscale/certificate_provider.go @@ -0,0 +1,98 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "crypto/tls" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + "github.com/sagernet/tailscale/client/local" +) + +func RegisterCertificateProvider(registry *certificate.Registry) { + certificate.Register[option.TailscaleCertificateProviderOptions](registry, C.TypeTailscale, NewCertificateProvider) +} + +var _ adapter.CertificateProviderService = (*CertificateProvider)(nil) + +type CertificateProvider struct { + certificate.Adapter + endpointTag string + endpoint *Endpoint + dialer N.Dialer + localClient *local.Client +} + +func NewCertificateProvider(ctx context.Context, _ log.ContextLogger, tag string, options option.TailscaleCertificateProviderOptions) (adapter.CertificateProviderService, error) { + if options.Endpoint == "" { + return nil, E.New("missing tailscale endpoint tag") + } + endpointManager := service.FromContext[adapter.EndpointManager](ctx) + if endpointManager == nil { + return nil, E.New("missing endpoint manager in context") + } + rawEndpoint, loaded := endpointManager.Get(options.Endpoint) + if !loaded { + return nil, E.New("endpoint not found: ", options.Endpoint) + } + endpoint, isTailscale := rawEndpoint.(*Endpoint) + if !isTailscale { + return nil, E.New("endpoint is not Tailscale: ", options.Endpoint) + } + providerDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: option.DialerOptions{}, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "create tailscale certificate provider dialer") + } + return &CertificateProvider{ + Adapter: certificate.NewAdapter(C.TypeTailscale, tag), + endpointTag: options.Endpoint, + endpoint: endpoint, + dialer: providerDialer, + }, nil +} + +func (p *CertificateProvider) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + localClient, err := p.endpoint.Server().LocalClient() + if err != nil { + return E.Cause(err, "initialize tailscale local client for endpoint ", p.endpointTag) + } + originalDial := localClient.Dial + localClient.Dial = func(ctx context.Context, network, addr string) (net.Conn, error) { + if originalDial != nil && addr == "local-tailscaled.sock:80" { + return originalDial(ctx, network, addr) + } + return p.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + } + p.localClient = localClient + return nil +} + +func (p *CertificateProvider) Close() error { + return nil +} + +func (p *CertificateProvider) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + localClient := p.localClient + if localClient == nil { + return nil, E.New("Tailscale is not ready yet") + } + return localClient.GetCertificate(clientHello) +} diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go new file mode 100644 index 00000000..3a92a66b --- /dev/null +++ b/protocol/tailscale/dns_transport.go @@ -0,0 +1,311 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + nDNS "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + + mDNS "github.com/miekg/dns" + "go4.org/netipx" + "golang.org/x/net/http2" +) + +func RegistryTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.TailscaleDNSServerOptions](registry, C.DNSTypeTailscale, NewDNSTransport) +} + +type DNSTransport struct { + dns.TransportAdapter + ctx context.Context + logger logger.ContextLogger + endpointTag string + acceptDefaultResolvers bool + dnsRouter adapter.DNSRouter + endpointManager adapter.EndpointManager + endpoint *Endpoint + routePrefixes []netip.Prefix + routes map[string][]adapter.DNSTransport + hosts map[string][]netip.Addr + defaultResolvers []adapter.DNSTransport +} + +func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleDNSServerOptions) (adapter.DNSTransport, error) { + if options.Endpoint == "" { + return nil, E.New("missing tailscale endpoint tag") + } + return &DNSTransport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeTailscale, tag, nil), + ctx: ctx, + logger: logger, + endpointTag: options.Endpoint, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + endpointManager: service.FromContext[adapter.EndpointManager](ctx), + }, nil +} + +func (t *DNSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + rawOutbound, loaded := t.endpointManager.Get(t.endpointTag) + if !loaded { + return E.New("endpoint not found: ", t.endpointTag) + } + ep, isTailscale := rawOutbound.(*Endpoint) + if !isTailscale { + return E.New("endpoint is not Tailscale: ", t.endpointTag) + } + if ep.onReconfigHook != nil { + return E.New("only one Tailscale DNS server is allowed for single endpoint") + } + ep.onReconfigHook = t.onReconfig + t.endpoint = ep + return nil +} + +func (t *DNSTransport) Reset() { +} + +func (t *DNSTransport) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *nDNS.Config) { + err := t.updateDNSServers(routerCfg, dnsCfg) + if err != nil { + t.logger.Error(E.Cause(err, "update DNS servers")) + } +} + +func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *nDNS.Config) error { + t.routePrefixes = buildRoutePrefixes(routeConfig) + directDialerOnce := sync.OnceValue(func() N.Dialer { + directDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{})) + return &DNSDialer{transport: t, fallbackDialer: directDialer} + }) + routes := make(map[string][]adapter.DNSTransport) + for domain, resolvers := range dnsConfig.Routes { + var myResolvers []adapter.DNSTransport + for _, resolver := range resolvers { + myResolver, err := t.createResolver(directDialerOnce, resolver) + if err != nil { + return err + } + myResolvers = append(myResolvers, myResolver) + } + routes[domain.WithTrailingDot()] = myResolvers + } + hosts := make(map[string][]netip.Addr) + for domain, addresses := range dnsConfig.Hosts { + hosts[domain.WithTrailingDot()] = addresses + } + var defaultResolvers []adapter.DNSTransport + for _, resolver := range dnsConfig.DefaultResolvers { + myResolver, err := t.createResolver(directDialerOnce, resolver) + if err != nil { + return err + } + defaultResolvers = append(defaultResolvers, myResolver) + } + t.routes = routes + t.hosts = hosts + t.defaultResolvers = defaultResolvers + if len(defaultResolvers) > 0 { + t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ", + strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " ")) + } else { + t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts") + } + return nil +} + +func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dnstype.Resolver) (adapter.DNSTransport, error) { + serverURL, parseURLErr := url.Parse(resolver.Addr) + var myDialer N.Dialer + if parseURLErr == nil && serverURL.Scheme == "http" { + myDialer = t.endpoint + } else { + myDialer = directDialer() + } + if len(resolver.BootstrapResolution) > 0 { + bootstrapTransport := transport.NewUDPRaw(t.logger, t.TransportAdapter, myDialer, M.SocksaddrFrom(resolver.BootstrapResolution[0], 53)) + myDialer = dialer.NewResolveDialer(t.ctx, myDialer, false, "", adapter.DNSQueryOptions{Transport: bootstrapTransport}, 0) + } + if serverAddr := M.ParseSocksaddr(resolver.Addr); serverAddr.IsValid() { + if serverAddr.Port == 0 { + serverAddr.Port = 53 + } + return transport.NewUDPRaw(t.logger, t.TransportAdapter, myDialer, serverAddr), nil + } else if parseURLErr != nil { + return nil, E.Cause(parseURLErr, "parse resolver address") + } else { + switch serverURL.Scheme { + case "https": + serverAddr = M.ParseSocksaddrHostPortStr(serverURL.Hostname(), serverURL.Port()) + if serverAddr.Port == 0 { + serverAddr.Port = 443 + } + tlsConfig := common.Must1(tls.NewClient(t.ctx, t.logger, serverAddr.AddrString(), option.OutboundTLSOptions{ + ALPN: []string{http2.NextProtoTLS, "http/1.1"}, + })) + return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, tlsConfig), nil + case "http": + serverAddr = M.ParseSocksaddrHostPortStr(serverURL.Hostname(), serverURL.Port()) + if serverAddr.Port == 0 { + serverAddr.Port = 80 + } + return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, nil), nil + // case "tls": + default: + return nil, E.New("unknown resolver scheme: ", serverURL.Scheme) + } + } +} + +func buildRoutePrefixes(routeConfig *router.Config) []netip.Prefix { + var builder netipx.IPSetBuilder + for _, localAddr := range routeConfig.LocalAddrs { + builder.AddPrefix(localAddr) + } + for _, route := range routeConfig.Routes { + builder.AddPrefix(route) + } + for _, route := range routeConfig.LocalRoutes { + builder.AddPrefix(route) + } + for _, route := range routeConfig.SubnetRoutes { + builder.AddPrefix(route) + } + ipSet, err := builder.IPSet() + if err != nil { + return nil + } + return ipSet.Prefixes() +} + +func (t *DNSTransport) Close() error { + return nil +} + +func (t *DNSTransport) Raw() bool { + return true +} + +func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if len(message.Question) != 1 { + return nil, os.ErrInvalid + } + question := message.Question[0] + addresses, hostsLoaded := t.hosts[question.Name] + if hostsLoaded { + switch question.Qtype { + case mDNS.TypeA: + addresses4 := common.Filter(addresses, func(addr netip.Addr) bool { + return addr.Is4() + }) + if len(addresses4) > 0 { + return dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil + } + case mDNS.TypeAAAA: + addresses6 := common.Filter(addresses, func(addr netip.Addr) bool { + return addr.Is6() + }) + if len(addresses6) > 0 { + return dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil + } + } + } + for domainSuffix, transports := range t.routes { + if strings.HasSuffix(question.Name, domainSuffix) { + if len(transports) == 0 { + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeNameError, + Response: true, + }, + Question: []mDNS.Question{question}, + }, nil + } + var lastErr error + for _, dnsTransport := range transports { + response, err := dnsTransport.Exchange(ctx, message) + if err != nil { + lastErr = err + continue + } + return response, nil + } + return nil, lastErr + } + } + if t.acceptDefaultResolvers { + if len(t.defaultResolvers) > 0 { + var lastErr error + for _, resolver := range t.defaultResolvers { + response, err := resolver.Exchange(ctx, message) + if err != nil { + lastErr = err + continue + } + return response, nil + } + return nil, lastErr + } else { + return nil, E.New("missing default resolvers") + } + } + return nil, dns.RcodeNameError +} + +type DNSDialer struct { + transport *DNSTransport + fallbackDialer N.Dialer +} + +func (d *DNSDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if destination.IsDomain() { + panic("invalid request here") + } + for _, prefix := range d.transport.routePrefixes { + if prefix.Contains(destination.Addr) { + return d.transport.endpoint.DialContext(ctx, network, destination) + } + } + return d.fallbackDialer.DialContext(ctx, network, destination) +} + +func (d *DNSDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if destination.IsDomain() { + panic("invalid request here") + } + for _, prefix := range d.transport.routePrefixes { + if prefix.Contains(destination.Addr) { + return d.transport.endpoint.ListenPacket(ctx, destination) + } + } + return d.fallbackDialer.ListenPacket(ctx, destination) +} diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go new file mode 100644 index 00000000..33b76930 --- /dev/null +++ b/protocol/tailscale/endpoint.go @@ -0,0 +1,860 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/ping" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" + _ "github.com/sagernet/tailscale/feature/relayserver" + "github.com/sagernet/tailscale/ipn" + tsDNS "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/tsaddr" + tsTUN "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/tsnet" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + + "go4.org/netipx" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ adapter.DirectRouteOutbound = (*Endpoint)(nil) + _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) +) + +func init() { + version.SetVersion("sing-box " + C.Version) +} + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.TailscaleEndpointOptions](registry, C.TypeTailscale, NewEndpoint) +} + +type Endpoint struct { + endpoint.Adapter + ctx context.Context + router adapter.Router + logger logger.ContextLogger + dnsRouter adapter.DNSRouter + network adapter.NetworkManager + platformInterface adapter.PlatformInterface + server *tsnet.Server + stack *stack.Stack + icmpForwarder *tun.ICMPForwarder + filter *atomic.Pointer[filter.Filter] + onReconfigHook wgengine.ReconfigListener + + cfg *wgcfg.Config + dnsCfg *tsDNS.Config + routeDomains common.TypedValue[map[string]bool] + routePrefixes atomic.Pointer[netipx.IPSet] + + acceptRoutes bool + exitNode string + exitNodeAllowLANAccess bool + advertiseRoutes []netip.Prefix + advertiseExitNode bool + advertiseTags []string + relayServerPort *uint16 + relayServerStaticEndpoints []netip.AddrPort + + udpTimeout time.Duration + + systemInterface bool + systemInterfaceName string + systemInterfaceMTU uint32 + systemTun tun.Tun + systemDialer *dialer.DefaultDialer + fallbackTCPCloser func() +} + +func (t *Endpoint) registerNetstackHandlers() { + netstack := t.server.ExportNetstack() + if netstack == nil { + return + } + previousTCP := netstack.GetTCPHandlerForFlow + netstack.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { + if previousTCP != nil { + handler, intercept = previousTCP(src, dst) + if handler != nil || !intercept { + return handler, intercept + } + } + return func(conn net.Conn) { + ctx := log.ContextWithNewID(t.ctx) + source := M.SocksaddrFrom(src.Addr(), src.Port()) + destination := M.SocksaddrFrom(dst.Addr(), dst.Port()) + t.NewConnectionEx(ctx, conn, source, destination, nil) + }, true + } + + previousUDP := netstack.GetUDPHandlerForFlow + netstack.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) { + if previousUDP != nil { + handler, intercept = previousUDP(src, dst) + if handler != nil || !intercept { + return handler, intercept + } + } + return func(conn nettype.ConnPacketConn) { + ctx := log.ContextWithNewID(t.ctx) + source := M.SocksaddrFrom(src.Addr(), src.Port()) + destination := M.SocksaddrFrom(dst.Addr(), dst.Port()) + packetConn := bufio.NewUnbindPacketConnWithAddr(conn, destination) + t.NewPacketConnectionEx(ctx, packetConn, source, destination, nil) + }, true + } +} + +func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailscaleEndpointOptions) (adapter.Endpoint, error) { + stateDirectory := options.StateDirectory + if stateDirectory == "" { + stateDirectory = "tailscale" + } + hostname := options.Hostname + if hostname == "" { + osHostname, _ := os.Hostname() + osHostname = strings.TrimSpace(osHostname) + hostname = osHostname + } + if hostname == "" { + hostname = "sing-box" + } + stateDirectory = filemanager.BasePath(ctx, os.ExpandEnv(stateDirectory)) + stateDirectory, _ = filepath.Abs(stateDirectory) + for _, advertiseRoute := range options.AdvertiseRoutes { + if advertiseRoute.Addr().IsUnspecified() && advertiseRoute.Bits() == 0 { + return nil, E.New("`advertise_routes` cannot be default, use `advertise_exit_node` instead.") + } + } + if options.AdvertiseExitNode && options.ExitNode != "" { + return nil, E.New("cannot advertise an exit node and use an exit node at the same time.") + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + var remoteIsDomain bool + if options.ControlURL != "" { + controlURL, err := url.Parse(options.ControlURL) + if err != nil { + return nil, E.Cause(err, "parse control URL") + } + remoteIsDomain = M.ParseSocksaddr(controlURL.Hostname()).IsDomain() + } else { + // controlplane.tailscale.com + remoteIsDomain = true + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: remoteIsDomain, + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + dnsRouter := service.FromContext[adapter.DNSRouter](ctx) + server := &tsnet.Server{ + Dir: stateDirectory, + Hostname: hostname, + Logf: func(format string, args ...any) { + logger.Trace(fmt.Sprintf(format, args...)) + }, + UserLogf: func(format string, args ...any) { + logger.Debug(fmt.Sprintf(format, args...)) + }, + Ephemeral: options.Ephemeral, + AuthKey: options.AuthKey, + ControlURL: options.ControlURL, + AdvertiseTags: options.AdvertiseTags, + Dialer: &endpointDialer{Dialer: outboundDialer, logger: logger}, + LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) { + return dnsRouter.Lookup(ctx, host, outboundDialer.(dialer.ResolveDialer).QueryOptions()) + }, + DNS: &dnsConfigurtor{}, + HTTPClient: &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(address)) + }, + TLSClientConfig: &tls.Config{ + RootCAs: adapter.RootPoolFromContext(ctx), + Time: ntp.TimeFuncFromContext(ctx), + }, + }, + }, + } + return &Endpoint{ + Adapter: endpoint.NewAdapter(C.TypeTailscale, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil), + ctx: ctx, + router: router, + logger: logger, + dnsRouter: dnsRouter, + network: service.FromContext[adapter.NetworkManager](ctx), + platformInterface: service.FromContext[adapter.PlatformInterface](ctx), + server: server, + acceptRoutes: options.AcceptRoutes, + exitNode: options.ExitNode, + exitNodeAllowLANAccess: options.ExitNodeAllowLANAccess, + advertiseRoutes: options.AdvertiseRoutes, + advertiseExitNode: options.AdvertiseExitNode, + advertiseTags: options.AdvertiseTags, + relayServerPort: options.RelayServerPort, + relayServerStaticEndpoints: options.RelayServerStaticEndpoints, + udpTimeout: udpTimeout, + systemInterface: options.SystemInterface, + systemInterfaceName: options.SystemInterfaceName, + systemInterfaceMTU: options.SystemInterfaceMTU, + }, nil +} + +func (t *Endpoint) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateStart: + return t.start() + case adapter.StartStatePostStart: + return t.postStart() + } + return nil +} + +func (t *Endpoint) start() error { + if t.platformInterface != nil { + err := t.network.UpdateInterfaces() + if err != nil { + return err + } + netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) { + return common.Map(t.network.InterfaceFinder().Interfaces(), func(it control.Interface) netmon.Interface { + return netmon.Interface{ + Interface: &net.Interface{ + Index: it.Index, + MTU: it.MTU, + Name: it.Name, + HardwareAddr: it.HardwareAddr, + Flags: it.Flags, + }, + AltAddrs: common.Map(it.Addresses, func(it netip.Prefix) net.Addr { + return &net.IPNet{ + IP: it.Addr().AsSlice(), + Mask: net.CIDRMask(it.Bits(), it.Addr().BitLen()), + } + }), + } + }), nil + }) + } + if t.systemInterface { + mtu := t.systemInterfaceMTU + if mtu == 0 { + mtu = uint32(tsTUN.DefaultTUNMTU()) + } + tunName := t.systemInterfaceName + if tunName == "" { + tunName = tun.CalculateInterfaceName("tailscale") + } + tunOptions := tun.Options{ + Name: tunName, + MTU: mtu, + GSO: true, + InterfaceScope: true, + InterfaceMonitor: t.network.InterfaceMonitor(), + InterfaceFinder: t.network.InterfaceFinder(), + Logger: t.logger, + EXP_ExternalConfiguration: true, + } + systemTun, err := tun.New(tunOptions) + if err != nil { + return err + } + err = systemTun.Start() + if err != nil { + _ = systemTun.Close() + return err + } + wgTunDevice, err := newTunDeviceAdapter(systemTun, int(mtu), t.logger) + if err != nil { + _ = systemTun.Close() + return err + } + systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ + BindInterface: tunName, + }) + if err != nil { + _ = systemTun.Close() + return err + } + t.systemTun = systemTun + t.systemDialer = systemDialer + t.server.TunDevice = wgTunDevice + } + if mark := t.network.AutoRedirectOutputMark(); mark > 0 { + controlFunc := t.network.AutoRedirectOutputMarkFunc() + if bindFunc := t.network.AutoDetectInterfaceFunc(); bindFunc != nil { + controlFunc = control.Append(controlFunc, bindFunc) + } + netns.SetControlFunc(controlFunc) + } else if runtime.GOOS == "android" && t.platformInterface != nil { + netns.SetControlFunc(func(network, address string, c syscall.RawConn) error { + return control.Raw(c, func(fd uintptr) error { + return t.platformInterface.AutoDetectInterfaceControl(int(fd)) + }) + }) + } + return nil +} + +func (t *Endpoint) postStart() error { + err := t.server.Start() + if err != nil { + if t.systemTun != nil { + _ = t.systemTun.Close() + } + return err + } + if t.fallbackTCPCloser == nil { + t.fallbackTCPCloser = t.server.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { + return func(conn net.Conn) { + ctx := log.ContextWithNewID(t.ctx) + source := M.SocksaddrFrom(src.Addr(), src.Port()) + destination := M.SocksaddrFrom(dst.Addr(), dst.Port()) + t.NewConnectionEx(ctx, conn, source, destination, nil) + }, true + }) + } + t.server.ExportLocalBackend().ExportEngine().(wgengine.ExportedUserspaceEngine).SetOnReconfigListener(t.onReconfig) + + ipStack := t.server.ExportNetstack().ExportIPStack() + gErr := ipStack.SetSpoofing(tun.DefaultNIC, true) + if gErr != nil { + return gonet.TranslateNetstackError(gErr) + } + gErr = ipStack.SetPromiscuousMode(tun.DefaultNIC, true) + if gErr != nil { + return gonet.TranslateNetstackError(gErr) + } + icmpForwarder := tun.NewICMPForwarder(t.ctx, ipStack, t, t.udpTimeout) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + t.stack = ipStack + t.icmpForwarder = icmpForwarder + t.registerNetstackHandlers() + + localBackend := t.server.ExportLocalBackend() + perfs := &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + RouteAll: t.acceptRoutes, + AdvertiseRoutes: t.advertiseRoutes, + }, + RouteAllSet: true, + ExitNodeIPSet: true, + AdvertiseRoutesSet: true, + RelayServerPortSet: true, + RelayServerStaticEndpointsSet: true, + } + if t.advertiseExitNode { + perfs.AdvertiseRoutes = append(perfs.AdvertiseRoutes, tsaddr.ExitRoutes()...) + } + if t.relayServerPort != nil { + perfs.RelayServerPort = t.relayServerPort + } + if len(t.relayServerStaticEndpoints) > 0 { + perfs.RelayServerStaticEndpoints = t.relayServerStaticEndpoints + } + _, err = localBackend.EditPrefs(perfs) + if err != nil { + return E.Cause(err, "update prefs") + } + t.filter = localBackend.ExportFilter() + go t.watchState() + return nil +} + +func (t *Endpoint) watchState() { + localBackend := t.server.ExportLocalBackend() + localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) { + if roNotify.State != nil && *roNotify.State != ipn.NeedsLogin && *roNotify.State != ipn.NoState { + return false + } + authURL := localBackend.StatusWithoutPeers().AuthURL + if authURL != "" { + t.logger.Info("Waiting for authentication: ", authURL) + if t.platformInterface != nil { + err := t.platformInterface.SendNotification(&adapter.Notification{ + Identifier: "tailscale-authentication", + TypeName: "Tailscale Authentication Notifications", + TypeID: 10, + Title: "Tailscale Authentication", + Body: F.ToString("Tailscale outbound[", t.Tag(), "] is waiting for authentication."), + OpenURL: authURL, + }) + if err != nil { + t.logger.Error("send authentication notification: ", err) + } + } + return false + } + return true + }) + if t.exitNode != "" { + localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) { + if roNotify.State == nil || *roNotify.State != ipn.Running { + return true + } + status, err := common.Must1(t.server.LocalClient()).Status(t.ctx) + if err != nil { + t.logger.Error("set exit node: ", err) + return + } + perfs := &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess, + }, + ExitNodeIPSet: true, + ExitNodeAllowLANAccessSet: true, + } + err = perfs.SetExitNodeIP(t.exitNode, status) + if err != nil { + t.logger.Error("set exit node: ", err) + return true + } + _, err = localBackend.EditPrefs(perfs) + if err != nil { + t.logger.Error("set exit node: ", err) + return true + } + return false + }) + } +} + +func (t *Endpoint) Close() error { + err := common.Close(common.PtrOrNil(t.server)) + netmon.RegisterInterfaceGetter(nil) + netns.SetControlFunc(nil) + if t.fallbackTCPCloser != nil { + t.fallbackTCPCloser() + t.fallbackTCPCloser = nil + } + if t.systemTun != nil { + t.systemTun.Close() + t.systemTun = nil + } + return err +} + +func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + t.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + t.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if destination.IsDomain() { + destinationAddresses, err := t.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, t, network, destination, destinationAddresses) + } + if t.systemDialer != nil { + return t.systemDialer.DialContext(ctx, network, destination) + } + addr4, addr6 := t.server.TailscaleIPs() + remoteAddr := tcpip.FullAddress{ + NIC: 1, + Port: destination.Port, + Addr: addressFromAddr(destination.Addr), + } + var localAddr tcpip.FullAddress + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !addr4.IsValid() { + return nil, E.New("missing Tailscale IPv4 address") + } + networkProtocol = header.IPv4ProtocolNumber + localAddr = tcpip.FullAddress{ + NIC: 1, + Addr: addressFromAddr(addr4), + } + } else { + if !addr6.IsValid() { + return nil, E.New("missing Tailscale IPv6 address") + } + networkProtocol = header.IPv6ProtocolNumber + localAddr = tcpip.FullAddress{ + NIC: 1, + Addr: addressFromAddr(addr6), + } + } + switch N.NetworkName(network) { + case N.NetworkTCP: + tcpConn, err := gonet.DialTCPWithBind(ctx, t.stack, localAddr, remoteAddr, networkProtocol) + if err != nil { + return nil, err + } + return tcpConn, nil + case N.NetworkUDP: + udpConn, err := gonet.DialUDP(t.stack, &localAddr, &remoteAddr, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (t *Endpoint) listenPacketWithAddress(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if t.systemDialer != nil { + return t.systemDialer.ListenPacket(ctx, destination) + } + addr4, addr6 := t.server.TailscaleIPs() + bind := tcpip.FullAddress{ + NIC: 1, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !addr4.IsValid() { + return nil, E.New("missing Tailscale IPv4 address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = addressFromAddr(addr4) + } else { + if !addr6.IsValid() { + return nil, E.New("missing Tailscale IPv6 address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = addressFromAddr(addr6) + } + udpConn, err := gonet.DialUDP(t.stack, &bind, nil, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil +} + +func (t *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + t.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if destination.IsDomain() { + destinationAddresses, err := t.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + var errors []error + for _, address := range destinationAddresses { + packetConn, packetErr := t.listenPacketWithAddress(ctx, M.SocksaddrFrom(address, destination.Port)) + if packetErr == nil { + return packetConn, address, nil + } + errors = append(errors, packetErr) + } + return nil, netip.Addr{}, E.Errors(errors...) + } + packetConn, err := t.listenPacketWithAddress(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (t *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := t.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (t *Endpoint) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + tsFilter := t.filter.Load() + if tsFilter != nil { + var ipProto ipproto.Proto + switch N.NetworkName(network) { + case N.NetworkTCP: + ipProto = ipproto.TCP + case N.NetworkUDP: + ipProto = ipproto.UDP + case N.NetworkICMP: + if !destination.IsIPv6() { + ipProto = ipproto.ICMPv4 + } else { + ipProto = ipproto.ICMPv6 + } + } + response := tsFilter.Check(source.Addr, destination.Addr, destination.Port, ipProto) + switch response { + case filter.Drop: + return nil, syscall.ECONNREFUSED + case filter.DropSilently: + return nil, tun.ErrDrop + } + } + var ipVersion uint8 + if !destination.IsIPv6() { + ipVersion = 4 + } else { + ipVersion = 6 + } + routeDestination, err := t.router.PreMatch(adapter.InboundContext{ + Inbound: t.Tag(), + InboundType: t.Type(), + IPVersion: ipVersion, + Network: network, + Source: source, + Destination: destination, + }, routeContext, timeout, false) + if err != nil { + switch { + case rule.IsBypassed(err): + err = nil + case rule.IsRejected(err): + t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()) + default: + if network == N.NetworkICMP { + t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())) + } + } + } + return routeDestination, err +} + +func (t *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = t.Tag() + metadata.InboundType = t.Type() + metadata.Source = source + addr4, addr6 := t.server.TailscaleIPs() + switch destination.Addr { + case addr4: + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + case addr6: + destination.Addr = netip.IPv6Loopback() + } + metadata.Destination = destination + t.logger.InfoContext(ctx, "inbound connection from ", source) + t.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + t.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = t.Tag() + metadata.InboundType = t.Type() + metadata.Source = source + addr4, addr6 := t.server.TailscaleIPs() + switch destination.Addr { + case addr4: + metadata.OriginDestination = destination + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + case addr6: + metadata.OriginDestination = destination + destination.Addr = netip.IPv6Loopback() + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + t.logger.InfoContext(ctx, "inbound packet connection from ", source) + t.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (t *Endpoint) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + ctx := log.ContextWithNewID(t.ctx) + var destination tun.DirectRouteDestination + var err error + if t.systemDialer != nil { + destination, err = ping.ConnectDestination( + ctx, t.logger, + t.systemDialer.DialerForICMPDestination(metadata.Destination.Addr).Control, + metadata.Destination.Addr, routeContext, timeout, + ) + } else { + inet4Address, inet6Address := t.server.TailscaleIPs() + if metadata.Destination.Addr.Is4() && !inet4Address.IsValid() || metadata.Destination.Addr.Is6() && !inet6Address.IsValid() { + return nil, E.New("Tailscale is not ready yet") + } + destination, err = ping.ConnectGVisor( + ctx, t.logger, + metadata.Source.Addr, metadata.Destination.Addr, + routeContext, + t.stack, + inet4Address, inet6Address, + timeout, + ) + } + if err != nil { + return nil, err + } + t.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString()) + return destination, nil +} + +func (t *Endpoint) PreferredDomain(domain string) bool { + routeDomains := t.routeDomains.Load() + if routeDomains == nil { + return false + } + return routeDomains[strings.ToLower(domain)] +} + +func (t *Endpoint) PreferredAddress(address netip.Addr) bool { + routePrefixes := t.routePrefixes.Load() + if routePrefixes == nil { + return false + } + return routePrefixes.Contains(address) +} + +func (t *Endpoint) Server() *tsnet.Server { + return t.server +} + +func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *tsDNS.Config) { + if cfg == nil || dnsCfg == nil { + return + } + if (t.cfg != nil && reflect.DeepEqual(t.cfg, cfg)) && (t.dnsCfg != nil && reflect.DeepEqual(t.dnsCfg, dnsCfg)) { + return + } + var inet4Address, inet6Address netip.Addr + for _, address := range cfg.Addresses { + if address.Addr().Is4() { + inet4Address = address.Addr() + } else if address.Addr().Is6() { + inet6Address = address.Addr() + } + } + t.icmpForwarder.SetLocalAddresses(inet4Address, inet6Address) + t.cfg = cfg + t.dnsCfg = dnsCfg + + routeDomains := make(map[string]bool) + for fqdn := range dnsCfg.Routes { + routeDomains[fqdn.WithoutTrailingDot()] = true + } + for _, fqdn := range dnsCfg.SearchDomains { + routeDomains[fqdn.WithoutTrailingDot()] = true + } + t.routeDomains.Store(routeDomains) + + var builder netipx.IPSetBuilder + for _, peer := range cfg.Peers { + for _, allowedIP := range peer.AllowedIPs { + builder.AddPrefix(allowedIP) + } + } + t.routePrefixes.Store(common.Must1(builder.IPSet())) + + if t.onReconfigHook != nil { + t.onReconfigHook(cfg, routerCfg, dnsCfg) + } +} + +func addressFromAddr(destination netip.Addr) tcpip.Address { + if destination.Is6() { + return tcpip.AddrFrom16(destination.As16()) + } else { + return tcpip.AddrFrom4(destination.As4()) + } +} + +type endpointDialer struct { + N.Dialer + logger logger.ContextLogger +} + +func (d *endpointDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + d.logger.InfoContext(ctx, "output connection to ", destination) + case N.NetworkUDP: + d.logger.InfoContext(ctx, "output packet connection to ", destination) + } + return d.Dialer.DialContext(ctx, network, destination) +} + +func (d *endpointDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + d.logger.InfoContext(ctx, "output packet connection") + return d.Dialer.ListenPacket(ctx, destination) +} + +type dnsConfigurtor struct { + baseConfig tsDNS.OSConfig +} + +func (c *dnsConfigurtor) SetDNS(cfg tsDNS.OSConfig) error { + c.baseConfig = cfg + return nil +} + +func (c *dnsConfigurtor) SupportsSplitDNS() bool { + return true +} + +func (c *dnsConfigurtor) GetBaseConfig() (tsDNS.OSConfig, error) { + return c.baseConfig, nil +} + +func (c *dnsConfigurtor) Close() error { + return nil +} diff --git a/protocol/tailscale/hostinfo_tvos.go b/protocol/tailscale/hostinfo_tvos.go new file mode 100644 index 00000000..d8e391bb --- /dev/null +++ b/protocol/tailscale/hostinfo_tvos.go @@ -0,0 +1,16 @@ +//go:build with_gvisor && tvos + +package tailscale + +import ( + _ "unsafe" + + "github.com/sagernet/tailscale/types/lazy" +) + +//go:linkname isAppleTV github.com/sagernet/tailscale/version.isAppleTV +var isAppleTV lazy.SyncValue[bool] + +func init() { + isAppleTV.Set(true) +} diff --git a/protocol/tailscale/ping.go b/protocol/tailscale/ping.go new file mode 100644 index 00000000..8bb0476b --- /dev/null +++ b/protocol/tailscale/ping.go @@ -0,0 +1,55 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/tailcfg" +) + +func (t *Endpoint) StartTailscalePing(ctx context.Context, peerIP string, fn func(*adapter.TailscalePingResult)) error { + ip, err := netip.ParseAddr(peerIP) + if err != nil { + return err + } + localClient, err := t.server.LocalClient() + if err != nil { + return err + } + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + result, pingErr := localClient.Ping(ctx, ip, tailcfg.PingDisco) + if ctx.Err() != nil { + return ctx.Err() + } + if pingErr != nil { + fn(&adapter.TailscalePingResult{ + Error: pingErr.Error(), + }) + } else { + fn(convertPingResult(result)) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func convertPingResult(result *ipnstate.PingResult) *adapter.TailscalePingResult { + return &adapter.TailscalePingResult{ + LatencyMs: result.LatencySeconds * 1000, + IsDirect: result.Endpoint != "", + Endpoint: result.Endpoint, + DERPRegionID: int32(result.DERPRegionID), + DERPRegionCode: result.DERPRegionCode, + Error: result.Err, + } +} diff --git a/protocol/tailscale/status.go b/protocol/tailscale/status.go new file mode 100644 index 00000000..a4d14ee1 --- /dev/null +++ b/protocol/tailscale/status.go @@ -0,0 +1,105 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "slices" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnstate" +) + +var _ adapter.TailscaleEndpoint = (*Endpoint)(nil) + +func (t *Endpoint) SubscribeTailscaleStatus(ctx context.Context, fn func(*adapter.TailscaleEndpointStatus)) error { + localBackend := t.server.ExportLocalBackend() + sendStatus := func() { + status := localBackend.Status() + fn(convertTailscaleStatus(status)) + } + sendStatus() + localBackend.WatchNotifications(ctx, ipn.NotifyInitialState|ipn.NotifyInitialNetMap|ipn.NotifyRateLimit, nil, func(roNotify *ipn.Notify) (keepGoing bool) { + select { + case <-ctx.Done(): + return false + default: + } + if roNotify.State != nil || roNotify.NetMap != nil || roNotify.BrowseToURL != nil { + sendStatus() + } + return true + }) + return ctx.Err() +} + +func convertTailscaleStatus(status *ipnstate.Status) *adapter.TailscaleEndpointStatus { + result := &adapter.TailscaleEndpointStatus{ + BackendState: status.BackendState, + AuthURL: status.AuthURL, + } + if status.CurrentTailnet != nil { + result.NetworkName = status.CurrentTailnet.Name + result.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix + } + if status.Self != nil { + result.Self = convertTailscalePeer(status.Self) + } + groupIndex := make(map[int64]*adapter.TailscaleUserGroup) + for _, peerKey := range status.Peers() { + peer := status.Peer[peerKey] + userID := int64(peer.UserID) + group, loaded := groupIndex[userID] + if !loaded { + group = &adapter.TailscaleUserGroup{ + UserID: userID, + } + if profile, hasProfile := status.User[peer.UserID]; hasProfile { + group.LoginName = profile.LoginName + group.DisplayName = profile.DisplayName + group.ProfilePicURL = profile.ProfilePicURL + } + groupIndex[userID] = group + result.UserGroups = append(result.UserGroups, group) + } + group.Peers = append(group.Peers, convertTailscalePeer(peer)) + } + for _, group := range result.UserGroups { + slices.SortStableFunc(group.Peers, func(a, b *adapter.TailscalePeer) int { + if a.Online != b.Online { + if a.Online { + return -1 + } + return 1 + } + return 0 + }) + } + return result +} + +func convertTailscalePeer(peer *ipnstate.PeerStatus) *adapter.TailscalePeer { + ips := make([]string, len(peer.TailscaleIPs)) + for i, ip := range peer.TailscaleIPs { + ips[i] = ip.String() + } + var keyExpiry int64 + if peer.KeyExpiry != nil { + keyExpiry = peer.KeyExpiry.Unix() + } + return &adapter.TailscalePeer{ + HostName: peer.HostName, + DNSName: peer.DNSName, + OS: peer.OS, + TailscaleIPs: ips, + Online: peer.Online, + ExitNode: peer.ExitNode, + ExitNodeOption: peer.ExitNodeOption, + Active: peer.Active, + RxBytes: peer.RxBytes, + TxBytes: peer.TxBytes, + UserID: int64(peer.UserID), + KeyExpiry: keyExpiry, + } +} diff --git a/protocol/tailscale/tun_device_unix.go b/protocol/tailscale/tun_device_unix.go new file mode 100644 index 00000000..a8d237ab --- /dev/null +++ b/protocol/tailscale/tun_device_unix.go @@ -0,0 +1,156 @@ +//go:build with_gvisor && !windows + +package tailscale + +import ( + "encoding/hex" + "errors" + "io" + "os" + "sync" + "sync/atomic" + + singTun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/logger" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type tunDeviceAdapter struct { + tun singTun.Tun + linuxTUN singTun.LinuxTUN + events chan wgTun.Event + mtu int + logger logger.ContextLogger + debugTun bool + readCount atomic.Uint32 + writeCount atomic.Uint32 + closeOnce sync.Once +} + +func newTunDeviceAdapter(tun singTun.Tun, mtu int, logger logger.ContextLogger) (wgTun.Device, error) { + if tun == nil { + return nil, os.ErrInvalid + } + if mtu == 0 { + mtu = 1500 + } + adapter := &tunDeviceAdapter{ + tun: tun, + events: make(chan wgTun.Event, 1), + mtu: mtu, + logger: logger, + debugTun: os.Getenv("SINGBOX_TS_TUN_DEBUG") != "", + } + if linuxTUN, ok := tun.(singTun.LinuxTUN); ok { + adapter.linuxTUN = linuxTUN + } + adapter.events <- wgTun.EventUp + return adapter, nil +} + +func (a *tunDeviceAdapter) File() *os.File { + return nil +} + +func (a *tunDeviceAdapter) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + if a.linuxTUN != nil { + n, err := a.linuxTUN.BatchRead(bufs, offset-singTun.PacketOffset, sizes) + if err == nil { + for i := 0; i < n; i++ { + a.debugPacket("read", bufs[i][offset:offset+sizes[i]]) + } + } + return n, err + } + if offset < singTun.PacketOffset { + return 0, io.ErrShortBuffer + } + readBuf := bufs[0][offset-singTun.PacketOffset:] + n, err := a.tun.Read(readBuf) + if err == nil { + if n < singTun.PacketOffset { + return 0, io.ErrUnexpectedEOF + } + sizes[0] = n - singTun.PacketOffset + a.debugPacket("read", readBuf[singTun.PacketOffset:n]) + return 1, nil + } + if errors.Is(err, singTun.ErrTooManySegments) { + err = wgTun.ErrTooManySegments + } + return 0, err +} + +func (a *tunDeviceAdapter) Write(bufs [][]byte, offset int) (count int, err error) { + if a.linuxTUN != nil { + for i := range bufs { + a.debugPacket("write", bufs[i][offset:]) + } + return a.linuxTUN.BatchWrite(bufs, offset) + } + for _, packet := range bufs { + a.debugPacket("write", packet[offset:]) + if singTun.PacketOffset > 0 { + common.ClearArray(packet[offset-singTun.PacketOffset : offset]) + singTun.PacketFillHeader(packet[offset-singTun.PacketOffset:], singTun.PacketIPVersion(packet[offset:])) + } + _, err = a.tun.Write(packet[offset-singTun.PacketOffset:]) + if err != nil { + return 0, err + } + } + // WireGuard will not read count. + return 0, nil +} + +func (a *tunDeviceAdapter) MTU() (int, error) { + return a.mtu, nil +} + +func (a *tunDeviceAdapter) Name() (string, error) { + return a.tun.Name() +} + +func (a *tunDeviceAdapter) Events() <-chan wgTun.Event { + return a.events +} + +func (a *tunDeviceAdapter) Close() error { + var err error + a.closeOnce.Do(func() { + close(a.events) + err = a.tun.Close() + }) + return err +} + +func (a *tunDeviceAdapter) BatchSize() int { + if a.linuxTUN != nil { + return a.linuxTUN.BatchSize() + } + return 1 +} + +func (a *tunDeviceAdapter) debugPacket(direction string, packet []byte) { + if !a.debugTun || a.logger == nil { + return + } + var counter *atomic.Uint32 + switch direction { + case "read": + counter = &a.readCount + case "write": + counter = &a.writeCount + default: + return + } + if counter.Add(1) > 8 { + return + } + sample := packet + if len(sample) > 64 { + sample = sample[:64] + } + a.logger.Trace("tailscale tun ", direction, " len=", len(packet), " head=", hex.EncodeToString(sample)) +} diff --git a/protocol/tailscale/tun_device_windows.go b/protocol/tailscale/tun_device_windows.go new file mode 100644 index 00000000..8c9e87ce --- /dev/null +++ b/protocol/tailscale/tun_device_windows.go @@ -0,0 +1,117 @@ +//go:build with_gvisor && windows + +package tailscale + +import ( + "errors" + "os" + "sync" + "sync/atomic" + + singTun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type tunDeviceAdapter struct { + tun singTun.WinTun + nativeTun *singTun.NativeTun + events chan wgTun.Event + mtu atomic.Int64 + closeOnce sync.Once +} + +func newTunDeviceAdapter(tun singTun.Tun, mtu int, _ logger.ContextLogger) (wgTun.Device, error) { + winTun, ok := tun.(singTun.WinTun) + if !ok { + return nil, errors.New("not a windows tun device") + } + nativeTun, ok := winTun.(*singTun.NativeTun) + if !ok { + return nil, errors.New("unsupported windows tun device") + } + if mtu == 0 { + mtu = 1500 + } + adapter := &tunDeviceAdapter{ + tun: winTun, + nativeTun: nativeTun, + events: make(chan wgTun.Event, 1), + } + adapter.mtu.Store(int64(mtu)) + adapter.events <- wgTun.EventUp + return adapter, nil +} + +func (a *tunDeviceAdapter) File() *os.File { + return nil +} + +func (a *tunDeviceAdapter) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + packet, release, err := a.tun.ReadPacket() + if err != nil { + return 0, err + } + defer release() + sizes[0] = copy(bufs[0][offset-singTun.PacketOffset:], packet) + return 1, nil +} + +func (a *tunDeviceAdapter) Write(bufs [][]byte, offset int) (count int, err error) { + for _, packet := range bufs { + if singTun.PacketOffset > 0 { + singTun.PacketFillHeader(packet[offset-singTun.PacketOffset:], singTun.PacketIPVersion(packet[offset:])) + } + _, err = a.tun.Write(packet[offset-singTun.PacketOffset:]) + if err != nil { + return 0, err + } + } + return 0, nil +} + +func (a *tunDeviceAdapter) MTU() (int, error) { + return int(a.mtu.Load()), nil +} + +func (a *tunDeviceAdapter) ForceMTU(mtu int) { + if mtu <= 0 { + return + } + update := int(a.mtu.Load()) != mtu + a.mtu.Store(int64(mtu)) + if update { + select { + case a.events <- wgTun.EventMTUUpdate: + default: + } + } +} + +func (a *tunDeviceAdapter) LUID() uint64 { + if a.nativeTun == nil { + return 0 + } + return a.nativeTun.LUID() +} + +func (a *tunDeviceAdapter) Name() (string, error) { + return a.tun.Name() +} + +func (a *tunDeviceAdapter) Events() <-chan wgTun.Event { + return a.events +} + +func (a *tunDeviceAdapter) Close() error { + var err error + a.closeOnce.Do(func() { + close(a.events) + err = a.tun.Close() + }) + return err +} + +func (a *tunDeviceAdapter) BatchSize() int { + return 1 +} diff --git a/protocol/tor/outbound.go b/protocol/tor/outbound.go new file mode 100644 index 00000000..9a0e2d65 --- /dev/null +++ b/protocol/tor/outbound.go @@ -0,0 +1,212 @@ +package tor + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" + "github.com/sagernet/sing/protocol/socks" + + "github.com/cretz/bine/control" + "github.com/cretz/bine/tor" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TorOutboundOptions](registry, C.TypeTor, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + proxy *ProxyListener + startConf *tor.StartConf + options map[string]string + events chan control.Event + instance *tor.Tor + socksClient *socks.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TorOutboundOptions) (adapter.Outbound, error) { + var startConf tor.StartConf + startConf.DataDir = os.ExpandEnv(options.DataDirectory) + startConf.TempDataDirBase = os.TempDir() + startConf.ExtraArgs = options.ExtraArgs + if options.DataDirectory != "" { + dataDirAbs, _ := filepath.Abs(startConf.DataDir) + if geoIPPath := filepath.Join(dataDirAbs, "geoip"); rw.IsFile(geoIPPath) && !common.Contains(options.ExtraArgs, "--GeoIPFile") { + options.ExtraArgs = append(options.ExtraArgs, "--GeoIPFile", geoIPPath) + } + if geoIP6Path := filepath.Join(dataDirAbs, "geoip6"); rw.IsFile(geoIP6Path) && !common.Contains(options.ExtraArgs, "--GeoIPv6File") { + options.ExtraArgs = append(options.ExtraArgs, "--GeoIPv6File", geoIP6Path) + } + } + if options.ExecutablePath != "" { + startConf.ExePath = options.ExecutablePath + startConf.ProcessCreator = nil + startConf.UseEmbeddedControlConn = false + } + if startConf.DataDir != "" { + torrcFile := filepath.Join(startConf.DataDir, "torrc") + err := rw.MkdirParent(torrcFile) + if err != nil { + return nil, err + } + if !rw.IsFile(torrcFile) { + err := os.WriteFile(torrcFile, []byte(""), 0o600) + if err != nil { + return nil, err + } + } + startConf.TorrcFile = torrcFile + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, false) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTor, tag, []string{N.NetworkTCP}, options.DialerOptions), + ctx: ctx, + logger: logger, + proxy: NewProxyListener(ctx, logger, outboundDialer), + startConf: &startConf, + options: options.Options, + }, nil +} + +func (t *Outbound) Start() error { + err := t.start() + if err != nil { + t.Close() + } + return err +} + +var torLogEvents = []control.EventCode{ + control.EventCodeLogDebug, + control.EventCodeLogErr, + control.EventCodeLogInfo, + control.EventCodeLogNotice, + control.EventCodeLogWarn, +} + +func (t *Outbound) start() error { + torInstance, err := tor.Start(t.ctx, t.startConf) + if err != nil { + return E.New(strings.ToLower(err.Error())) + } + t.instance = torInstance + t.events = make(chan control.Event, 8) + err = torInstance.Control.AddEventListener(t.events, torLogEvents...) + if err != nil { + return err + } + go t.recvLoop() + err = t.proxy.Start() + if err != nil { + return err + } + proxyPort := "127.0.0.1:" + F.ToString(t.proxy.Port()) + proxyUsername := t.proxy.Username() + proxyPassword := t.proxy.Password() + t.logger.Trace("created upstream proxy at ", proxyPort) + t.logger.Trace("upstream proxy username ", proxyUsername) + t.logger.Trace("upstream proxy password ", proxyPassword) + confOptions := []*control.KeyVal{ + control.NewKeyVal("Socks5Proxy", proxyPort), + control.NewKeyVal("Socks5ProxyUsername", proxyUsername), + control.NewKeyVal("Socks5ProxyPassword", proxyPassword), + } + err = torInstance.Control.ResetConf(confOptions...) + if err != nil { + return err + } + if len(t.options) > 0 { + for key, value := range t.options { + switch key { + case "Socks5Proxy", + "Socks5ProxyUsername", + "Socks5ProxyPassword": + continue + } + err = torInstance.Control.SetConf(control.NewKeyVal(key, value)) + if err != nil { + return E.Cause(err, "set ", key, "=", value) + } + } + } + err = torInstance.EnableNetwork(t.ctx, true) + if err != nil { + return err + } + info, err := torInstance.Control.GetInfo("net/listeners/socks") + if err != nil { + return err + } + if len(info) != 1 || info[0].Key != "net/listeners/socks" { + return E.New("get socks proxy address") + } + t.logger.Trace("obtained tor socks5 address ", info[0].Val) + // TODO: set password for tor socks5 server if supported + t.socksClient = socks.NewClient(N.SystemDialer, M.ParseSocksaddr(info[0].Val), socks.Version5, "", "") + return nil +} + +func (t *Outbound) recvLoop() { + for rawEvent := range t.events { + switch event := rawEvent.(type) { + case *control.LogEvent: + event.Raw = strings.ToLower(event.Raw) + switch event.Severity { + case control.EventCodeLogDebug, control.EventCodeLogInfo: + t.logger.Trace(event.Raw) + case control.EventCodeLogNotice: + if strings.Contains(event.Raw, "disablenetwork") || strings.Contains(event.Raw, "socks listener") { + t.logger.Trace(event.Raw) + continue + } + t.logger.Info(event.Raw) + case control.EventCodeLogWarn: + t.logger.Warn(event.Raw) + case control.EventCodeLogErr: + t.logger.Error(event.Raw) + } + } + } +} + +func (t *Outbound) Close() error { + err := common.Close( + common.PtrOrNil(t.proxy), + common.PtrOrNil(t.instance), + ) + if t.events != nil { + close(t.events) + t.events = nil + } + return err +} + +func (t *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + t.logger.InfoContext(ctx, "outbound connection to ", destination) + return t.socksClient.DialContext(ctx, network, destination) +} + +func (t *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} diff --git a/protocol/tor/proxy.go b/protocol/tor/proxy.go new file mode 100644 index 00000000..378e74fc --- /dev/null +++ b/protocol/tor/proxy.go @@ -0,0 +1,121 @@ +package tor + +import ( + std_bufio "bufio" + "context" + "crypto/rand" + "encoding/hex" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/service" +) + +type ProxyListener struct { + ctx context.Context + logger log.ContextLogger + dialer N.Dialer + connection adapter.ConnectionManager + tcpListener *net.TCPListener + username string + password string + authenticator *auth.Authenticator +} + +func NewProxyListener(ctx context.Context, logger log.ContextLogger, dialer N.Dialer) *ProxyListener { + var usernameB [64]byte + var passwordB [64]byte + rand.Read(usernameB[:]) + rand.Read(passwordB[:]) + username := hex.EncodeToString(usernameB[:]) + password := hex.EncodeToString(passwordB[:]) + return &ProxyListener{ + ctx: ctx, + logger: logger, + dialer: dialer, + connection: service.FromContext[adapter.ConnectionManager](ctx), + authenticator: auth.NewAuthenticator([]auth.User{{Username: username, Password: password}}), + username: username, + password: password, + } +} + +func (l *ProxyListener) Start() error { + tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{ + IP: net.IPv4(127, 0, 0, 1), + }) + if err != nil { + return err + } + l.tcpListener = tcpListener + go l.acceptLoop() + return nil +} + +func (l *ProxyListener) Port() uint16 { + if l.tcpListener == nil { + panic("start listener first") + } + return M.SocksaddrFromNet(l.tcpListener.Addr()).Port +} + +func (l *ProxyListener) Username() string { + return l.username +} + +func (l *ProxyListener) Password() string { + return l.password +} + +func (l *ProxyListener) Close() error { + return common.Close(l.tcpListener) +} + +func (l *ProxyListener) acceptLoop() { + for { + tcpConn, err := l.tcpListener.AcceptTCP() + if err != nil { + return + } + ctx := log.ContextWithNewID(l.ctx) + go func() { + hErr := l.accept(ctx, tcpConn) + if hErr != nil { + if E.IsClosedOrCanceled(hErr) { + l.logger.DebugContext(ctx, E.Cause(hErr, "proxy connection closed")) + return + } + l.logger.ErrorContext(ctx, E.Cause(hErr, "proxy")) + } + }() + } +} + +func (l *ProxyListener) accept(ctx context.Context, conn *net.TCPConn) error { + return socks.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), l.authenticator, l, nil, 0, M.SocksaddrFromNet(conn.RemoteAddr()), nil) +} + +func (l *ProxyListener) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Source = source + metadata.Destination = destination + metadata.Network = N.NetworkTCP + l.logger.InfoContext(ctx, "proxy connection to ", metadata.Destination) + l.connection.NewConnection(ctx, l.dialer, conn, metadata, onClose) +} + +func (l *ProxyListener) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Source = source + metadata.Destination = destination + metadata.Network = N.NetworkUDP + l.logger.InfoContext(ctx, "proxy packet connection to ", metadata.Destination) + l.connection.NewPacketConnection(ctx, l.dialer, conn, metadata, onClose) +} diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go new file mode 100644 index 00000000..6e11c088 --- /dev/null +++ b/protocol/trojan/inbound.go @@ -0,0 +1,262 @@ +package trojan + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/trojan" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.TrojanInboundOptions](registry, C.TypeTrojan, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + service *trojan.Service[int] + users []option.TrojanUser + tlsConfig tls.ServerConfig + fallbackAddr M.Socksaddr + fallbackAddrTLSNextProto map[string]M.Socksaddr + transport adapter.V2RayServerTransport +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrojanInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTrojan, tag), + router: router, + logger: logger, + users: options.Users, + } + if options.TLS != nil { + tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ + Context: ctx, + Logger: logger, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: common.PtrValueOrDefault(options.Transport).Type == "" && + !common.PtrValueOrDefault(options.Multiplex).Enabled, + }) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + var fallbackHandler N.TCPConnectionHandlerEx + if options.Fallback != nil && options.Fallback.Server != "" || len(options.FallbackForALPN) > 0 { + if options.Fallback != nil && options.Fallback.Server != "" { + inbound.fallbackAddr = options.Fallback.Build() + if !inbound.fallbackAddr.IsValid() { + return nil, E.New("invalid fallback address: ", inbound.fallbackAddr) + } + } + if len(options.FallbackForALPN) > 0 { + if inbound.tlsConfig == nil { + return nil, E.New("fallback for ALPN is not supported without TLS") + } + fallbackAddrNextProto := make(map[string]M.Socksaddr) + for nextProto, destination := range options.FallbackForALPN { + fallbackAddr := destination.Build() + if !fallbackAddr.IsValid() { + return nil, E.New("invalid fallback address for ALPN ", nextProto, ": ", fallbackAddr) + } + fallbackAddrNextProto[nextProto] = fallbackAddr + } + inbound.fallbackAddrTLSNextProto = fallbackAddrNextProto + } + fallbackHandler = adapter.NewUpstreamContextHandlerEx(inbound.fallbackConnection, nil) + } + service := trojan.NewService[int](adapter.NewUpstreamContextHandlerEx(inbound.newConnection, inbound.newPacketConnection), fallbackHandler, logger) + err := service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.TrojanUser) int { + return index + }), common.Map(options.Users, func(it option.TrojanUser) string { + return it.Password + })) + if err != nil { + return nil, err + } + if options.Transport != nil { + inbound.transport, err = v2ray.NewServerTransport(ctx, logger, common.PtrValueOrDefault(options.Transport), inbound.tlsConfig, (*inboundTransportHandler)(inbound)) + if err != nil { + return nil, E.Cause(err, "create server transport: ", options.Transport.Type) + } + } + inbound.router, err = mux.NewRouterWithOptions(inbound.router, logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + inbound.service = service + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + if h.transport == nil { + return h.listener.Start() + } + if common.Contains(h.transport.Network(), N.NetworkTCP) { + tcpListener, err := h.listener.ListenTCP() + if err != nil { + return err + } + go func() { + sErr := h.transport.Serve(tcpListener) + if sErr != nil && !E.IsClosed(sErr) { + h.logger.Error("transport serve error: ", sErr) + } + }() + } + if common.Contains(h.transport.Network(), N.NetworkUDP) { + udpConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + go func() { + sErr := h.transport.ServePacket(udpConn) + if sErr != nil && !E.IsClosed(sErr) { + h.logger.Error("transport serve error: ", sErr) + } + }() + } + return nil +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + h.tlsConfig, + h.transport, + ) +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if h.tlsConfig != nil && h.transport == nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return + } + conn = tlsConn + } + err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } +} + +func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) fallbackConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + var fallbackAddr M.Socksaddr + if len(h.fallbackAddrTLSNextProto) > 0 { + if tlsConn, loaded := common.Cast[tls.Conn](conn); loaded { + connectionState := tlsConn.ConnectionState() + if connectionState.NegotiatedProtocol != "" { + if fallbackAddr, loaded = h.fallbackAddrTLSNextProto[connectionState.NegotiatedProtocol]; !loaded { + h.logger.DebugContext(ctx, "process connection from ", metadata.Source, ": fallback disabled for ALPN: ", connectionState.NegotiatedProtocol) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + } + } + } + if !fallbackAddr.IsValid() { + if !h.fallbackAddr.IsValid() { + h.logger.DebugContext(ctx, "process connection from ", metadata.Source, ": fallback disabled by default") + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + fallbackAddr = h.fallbackAddr + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + metadata.Destination = fallbackAddr + h.logger.InfoContext(ctx, "fallback connection to ", fallbackAddr) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +var _ adapter.V2RayServerTransportHandler = (*inboundTransportHandler)(nil) + +type inboundTransportHandler Inbound + +func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Source = source + metadata.Destination = destination + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + (*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/trojan/outbound.go b/protocol/trojan/outbound.go new file mode 100644 index 00000000..26c7c81f --- /dev/null +++ b/protocol/trojan/outbound.go @@ -0,0 +1,158 @@ +package trojan + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/trojan" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TrojanOutboundOptions](registry, C.TypeTrojan, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + dialer N.Dialer + serverAddr M.Socksaddr + key [56]byte + multiplexDialer *mux.Client + tlsConfig tls.Config + tlsDialer tls.Dialer + transport adapter.V2RayClientTransport +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrojanOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTrojan, tag, options.Network.Build(), options.DialerOptions), + logger: logger, + dialer: outboundDialer, + serverAddr: options.ServerOptions.Build(), + key: trojan.Key(options.Password), + } + if options.TLS != nil { + outbound.tlsConfig, err = tls.NewClientWithOptions(tls.ClientOptions{ + Context: ctx, + Logger: logger, + ServerAddress: options.Server, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: common.PtrValueOrDefault(options.Transport).Type == "" && + !common.PtrValueOrDefault(options.Multiplex).Enabled, + }) + if err != nil { + return nil, err + } + outbound.tlsDialer = tls.NewDialer(outboundDialer, outbound.tlsConfig) + } + if options.Transport != nil { + outbound.transport, err = v2ray.NewClientTransport(ctx, outbound.dialer, outbound.serverAddr, common.PtrValueOrDefault(options.Transport), outbound.tlsConfig) + if err != nil { + return nil, E.Cause(err, "create client transport: ", options.Transport.Type) + } + } + outbound.multiplexDialer, err = mux.NewClientWithOptions((*trojanDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if h.multiplexDialer == nil { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + return (*trojanDialer)(h).DialContext(ctx, network, destination) + } else { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound multiplex connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + } + return h.multiplexDialer.DialContext(ctx, network, destination) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if h.multiplexDialer == nil { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return (*trojanDialer)(h).ListenPacket(ctx, destination) + } else { + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + return h.multiplexDialer.ListenPacket(ctx, destination) + } +} + +func (h *Outbound) InterfaceUpdated() { + if h.transport != nil { + h.transport.Close() + } + if h.multiplexDialer != nil { + h.multiplexDialer.Reset() + } +} + +func (h *Outbound) Close() error { + return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) +} + +type trojanDialer Outbound + +func (h *trojanDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + var conn net.Conn + var err error + if h.transport != nil { + conn, err = h.transport.DialContext(ctx) + } else if h.tlsDialer != nil { + conn, err = h.tlsDialer.DialTLSContext(ctx, h.serverAddr) + } else { + conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + } + if err != nil { + common.Close(conn) + return nil, err + } + switch N.NetworkName(network) { + case N.NetworkTCP: + return trojan.NewClientConn(conn, h.key, destination), nil + case N.NetworkUDP: + return bufio.NewBindPacketConn(trojan.NewClientPacketConn(conn, h.key), destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *trojanDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + conn, err := h.DialContext(ctx, N.NetworkUDP, destination) + if err != nil { + return nil, err + } + return conn.(net.PacketConn), nil +} diff --git a/protocol/tuic/inbound.go b/protocol/tuic/inbound.go new file mode 100644 index 00000000..600c7f93 --- /dev/null +++ b/protocol/tuic/inbound.go @@ -0,0 +1,170 @@ +package tuic + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic/tuic" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/gofrs/uuid/v5" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.TUICInboundOptions](registry, C.TypeTUIC, NewInbound) +} + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + server *tuic.Service[int] + userNameList []string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICInboundOptions) (adapter.Inbound, error) { + options.UDPFragmentDefault = true + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTUIC, tag), + router: uot.NewRouter(router, logger), + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + tlsConfig: tlsConfig, + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + service, err := tuic.NewService[int](tuic.ServiceOptions{ + Context: ctx, + Logger: logger, + TLSConfig: tlsConfig, + CongestionControl: options.CongestionControl, + AuthTimeout: time.Duration(options.AuthTimeout), + ZeroRTTHandshake: options.ZeroRTTHandshake, + Heartbeat: time.Duration(options.Heartbeat), + UDPTimeout: udpTimeout, + Handler: inbound, + }) + if err != nil { + return nil, err + } + var userList []int + var userNameList []string + var userUUIDList [][16]byte + var userPasswordList []string + for index, user := range options.Users { + if user.UUID == "" { + return nil, E.New("missing uuid for user ", index) + } + userUUID, err := uuid.FromString(user.UUID) + if err != nil { + return nil, E.Cause(err, "invalid uuid for user ", index) + } + userList = append(userList, index) + userNameList = append(userNameList, user.Name) + userUUIDList = append(userUUIDList, userUUID) + userPasswordList = append(userPasswordList, user.Password) + } + service.UpdateUsers(userList, userUUIDList, userPasswordList) + inbound.server = service + inbound.userNameList = userNameList + return inbound, nil +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.OriginDestination = h.listener.UDPAddr() + metadata.Source = source + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + userID, _ := auth.UserFromContext[int](ctx) + if userName := h.userNameList[userID]; userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + metadata.OriginDestination = h.listener.UDPAddr() + metadata.Source = source + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) + userID, _ := auth.UserFromContext[int](ctx) + if userName := h.userNameList[userID]; userName != "" { + metadata.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound packet connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return err + } + } + packetConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + return h.server.Start(packetConn) +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + h.tlsConfig, + common.PtrOrNil(h.server), + ) +} diff --git a/protocol/tuic/outbound.go b/protocol/tuic/outbound.go new file mode 100644 index 00000000..94d3cb77 --- /dev/null +++ b/protocol/tuic/outbound.go @@ -0,0 +1,141 @@ +package tuic + +import ( + "context" + "net" + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic/tuic" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" + + "github.com/gofrs/uuid/v5" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TUICOutboundOptions](registry, C.TypeTUIC, NewOutbound) +} + +var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + client *tuic.Client + udpStream bool +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICOutboundOptions) (adapter.Outbound, error) { + options.UDPFragmentDefault = true + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + userUUID, err := uuid.FromString(options.UUID) + if err != nil { + return nil, E.Cause(err, "invalid uuid") + } + var tuicUDPStream bool + if options.UDPOverStream && options.UDPRelayMode != "" { + return nil, E.New("udp_over_stream is conflict with udp_relay_mode") + } + switch options.UDPRelayMode { + case "native": + case "quic": + tuicUDPStream = true + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + client, err := tuic.NewClient(tuic.ClientOptions{ + Context: ctx, + Dialer: outboundDialer, + ServerAddress: options.ServerOptions.Build(), + TLSConfig: tlsConfig, + UUID: userUUID, + Password: options.Password, + CongestionControl: options.CongestionControl, + UDPStream: tuicUDPStream, + ZeroRTTHandshake: options.ZeroRTTHandshake, + Heartbeat: time.Duration(options.Heartbeat), + }) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTUIC, tag, options.Network.Build(), options.DialerOptions), + logger: logger, + client: client, + udpStream: options.UDPOverStream, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialConn(ctx, destination) + case N.NetworkUDP: + if h.udpStream { + h.logger.InfoContext(ctx, "outbound stream packet connection to ", destination) + streamConn, err := h.client.DialConn(ctx, uot.RequestDestination(uot.Version)) + if err != nil { + return nil, err + } + return uot.NewLazyConn(streamConn, uot.Request{ + IsConnect: true, + Destination: destination, + }), nil + } else { + conn, err := h.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(conn, destination), nil + } + default: + return nil, E.New("unsupported network: ", network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if h.udpStream { + h.logger.InfoContext(ctx, "outbound stream packet connection to ", destination) + streamConn, err := h.client.DialConn(ctx, uot.RequestDestination(uot.Version)) + if err != nil { + return nil, err + } + return uot.NewLazyConn(streamConn, uot.Request{ + IsConnect: false, + Destination: destination, + }), nil + } else { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx) + } +} + +func (h *Outbound) InterfaceUpdated() { + _ = h.client.CloseWithError(E.New("network changed")) +} + +func (h *Outbound) Close() error { + return h.client.CloseWithError(os.ErrClosed) +} diff --git a/protocol/tun/hook.go b/protocol/tun/hook.go new file mode 100644 index 00000000..1afa643f --- /dev/null +++ b/protocol/tun/hook.go @@ -0,0 +1,3 @@ +package tun + +var HookBeforeCreatePlatformInterface func() diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go new file mode 100644 index 00000000..4b113f4a --- /dev/null +++ b/protocol/tun/inbound.go @@ -0,0 +1,559 @@ +package tun + +import ( + "context" + "net" + "net/netip" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ranges" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + "go4.org/netipx" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.TunInboundOptions](registry, C.TypeTun, NewInbound) +} + +type Inbound struct { + tag string + ctx context.Context + router adapter.Router + networkManager adapter.NetworkManager + logger log.ContextLogger + tunOptions tun.Options + udpTimeout time.Duration + stack string + tunIf tun.Tun + tunStack tun.Stack + platformInterface adapter.PlatformInterface + platformOptions option.TunPlatformOptions + autoRedirect tun.AutoRedirect + routeRuleSet []adapter.RuleSet + routeRuleSetCallback []*list.Element[adapter.RuleSetUpdateCallback] + routeExcludeRuleSet []adapter.RuleSet + routeExcludeRuleSetCallback []*list.Element[adapter.RuleSetUpdateCallback] + routeAddressSet []*netipx.IPSet + routeExcludeAddressSet []*netipx.IPSet +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunInboundOptions) (adapter.Inbound, error) { + //nolint:staticcheck + if len(options.Inet4Address) > 0 || len(options.Inet6Address) > 0 || + len(options.Inet4RouteAddress) > 0 || len(options.Inet6RouteAddress) > 0 || + len(options.Inet4RouteExcludeAddress) > 0 || len(options.Inet6RouteExcludeAddress) > 0 { + return nil, E.New("legacy tun address fields are deprecated in sing-box 1.10.0 and removed in sing-box 1.12.0") + } + //nolint:staticcheck + if options.GSO { + return nil, E.New("GSO option in tun is deprecated in sing-box 1.11.0 and removed in sing-box 1.12.0") + } + //nolint:staticcheck + if options.InboundOptions != (option.InboundOptions{}) { + return nil, E.New("legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-legacy-inbound-fields-to-rule-actions") + } + + address := options.Address + inet4Address := common.Filter(address, func(it netip.Prefix) bool { + return it.Addr().Is4() + }) + inet6Address := common.Filter(address, func(it netip.Prefix) bool { + return it.Addr().Is6() + }) + + routeAddress := options.RouteAddress + inet4RouteAddress := common.Filter(routeAddress, func(it netip.Prefix) bool { + return it.Addr().Is4() + }) + inet6RouteAddress := common.Filter(routeAddress, func(it netip.Prefix) bool { + return it.Addr().Is6() + }) + + routeExcludeAddress := options.RouteExcludeAddress + inet4RouteExcludeAddress := common.Filter(routeExcludeAddress, func(it netip.Prefix) bool { + return it.Addr().Is4() + }) + inet6RouteExcludeAddress := common.Filter(routeExcludeAddress, func(it netip.Prefix) bool { + return it.Addr().Is6() + }) + + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + tunMTU := options.MTU + enableGSO := C.IsLinux && options.Stack == "gvisor" && platformInterface == nil && tunMTU > 0 && tunMTU < 49152 + if tunMTU == 0 { + if platformInterface != nil && platformInterface.UnderNetworkExtension() { + // In Network Extension, when MTU exceeds 4064 (4096-UTUN_IF_HEADROOM_SIZE), the performance of tun will drop significantly, which may be a system bug. + tunMTU = 4064 + } else if C.IsAndroid { + // Some Android devices report ENOBUFS when using MTU 65535 + tunMTU = 9000 + } else { + tunMTU = 65535 + } + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + var err error + includeUID := uidToRange(options.IncludeUID) + if len(options.IncludeUIDRange) > 0 { + includeUID, err = parseRange(includeUID, options.IncludeUIDRange) + if err != nil { + return nil, E.Cause(err, "parse include_uid_range") + } + } + excludeUID := uidToRange(options.ExcludeUID) + if len(options.ExcludeUIDRange) > 0 { + excludeUID, err = parseRange(excludeUID, options.ExcludeUIDRange) + if err != nil { + return nil, E.Cause(err, "parse exclude_uid_range") + } + } + + tableIndex := options.IPRoute2TableIndex + if tableIndex == 0 { + tableIndex = tun.DefaultIPRoute2TableIndex + } + ruleIndex := options.IPRoute2RuleIndex + if ruleIndex == 0 { + ruleIndex = tun.DefaultIPRoute2RuleIndex + } + autoRedirectFallbackRuleIndex := options.AutoRedirectFallbackRuleIndex + if autoRedirectFallbackRuleIndex == 0 { + autoRedirectFallbackRuleIndex = tun.DefaultIPRoute2AutoRedirectFallbackRuleIndex + } + inputMark := uint32(options.AutoRedirectInputMark) + if inputMark == 0 { + inputMark = tun.DefaultAutoRedirectInputMark + } + outputMark := uint32(options.AutoRedirectOutputMark) + if outputMark == 0 { + outputMark = tun.DefaultAutoRedirectOutputMark + } + resetMark := uint32(options.AutoRedirectResetMark) + if resetMark == 0 { + resetMark = tun.DefaultAutoRedirectResetMark + } + nfQueue := options.AutoRedirectNFQueue + if nfQueue == 0 { + nfQueue = tun.DefaultAutoRedirectNFQueue + } + var includeMACAddress []net.HardwareAddr + for i, macString := range options.IncludeMACAddress { + mac, macErr := net.ParseMAC(macString) + if macErr != nil { + return nil, E.Cause(macErr, "parse include_mac_address[", i, "]") + } + includeMACAddress = append(includeMACAddress, mac) + } + var excludeMACAddress []net.HardwareAddr + for i, macString := range options.ExcludeMACAddress { + mac, macErr := net.ParseMAC(macString) + if macErr != nil { + return nil, E.Cause(macErr, "parse exclude_mac_address[", i, "]") + } + excludeMACAddress = append(excludeMACAddress, mac) + } + networkManager := service.FromContext[adapter.NetworkManager](ctx) + multiPendingPackets := C.IsDarwin && ((options.Stack == "gvisor" && tunMTU < 32768) || (options.Stack != "gvisor" && options.MTU <= 9000)) + inbound := &Inbound{ + tag: tag, + ctx: ctx, + router: router, + networkManager: networkManager, + logger: logger, + tunOptions: tun.Options{ + Name: options.InterfaceName, + MTU: tunMTU, + GSO: enableGSO, + Inet4Address: inet4Address, + Inet6Address: inet6Address, + AutoRoute: options.AutoRoute, + IPRoute2TableIndex: tableIndex, + IPRoute2RuleIndex: ruleIndex, + IPRoute2AutoRedirectFallbackRuleIndex: autoRedirectFallbackRuleIndex, + AutoRedirectInputMark: inputMark, + AutoRedirectOutputMark: outputMark, + AutoRedirectResetMark: resetMark, + AutoRedirectNFQueue: nfQueue, + ExcludeMPTCP: options.ExcludeMPTCP, + Inet4LoopbackAddress: common.Filter(options.LoopbackAddress, netip.Addr.Is4), + Inet6LoopbackAddress: common.Filter(options.LoopbackAddress, netip.Addr.Is6), + StrictRoute: options.StrictRoute, + IncludeInterface: options.IncludeInterface, + ExcludeInterface: options.ExcludeInterface, + Inet4RouteAddress: inet4RouteAddress, + Inet6RouteAddress: inet6RouteAddress, + Inet4RouteExcludeAddress: inet4RouteExcludeAddress, + Inet6RouteExcludeAddress: inet6RouteExcludeAddress, + IncludeUID: includeUID, + ExcludeUID: excludeUID, + IncludeAndroidUser: options.IncludeAndroidUser, + IncludePackage: options.IncludePackage, + ExcludePackage: options.ExcludePackage, + IncludeMACAddress: includeMACAddress, + ExcludeMACAddress: excludeMACAddress, + InterfaceMonitor: networkManager.InterfaceMonitor(), + EXP_MultiPendingPackets: multiPendingPackets, + }, + udpTimeout: udpTimeout, + stack: options.Stack, + platformInterface: platformInterface, + platformOptions: common.PtrValueOrDefault(options.Platform), + } + for _, routeAddressSet := range options.RouteAddressSet { + ruleSet, loaded := router.RuleSet(routeAddressSet) + if !loaded { + return nil, E.New("parse route_address_set: rule-set not found: ", routeAddressSet) + } + inbound.routeRuleSet = append(inbound.routeRuleSet, ruleSet) + } + for _, routeExcludeAddressSet := range options.RouteExcludeAddressSet { + ruleSet, loaded := router.RuleSet(routeExcludeAddressSet) + if !loaded { + return nil, E.New("parse route_exclude_address_set: rule-set not found: ", routeExcludeAddressSet) + } + inbound.routeExcludeRuleSet = append(inbound.routeExcludeRuleSet, ruleSet) + } + if options.AutoRedirect { + if !options.AutoRoute { + return nil, E.New("`auto_route` is required by `auto_redirect`") + } + disableNFTables, dErr := strconv.ParseBool(os.Getenv("DISABLE_NFTABLES")) + inbound.autoRedirect, err = tun.NewAutoRedirect(tun.AutoRedirectOptions{ + TunOptions: &inbound.tunOptions, + Context: ctx, + Handler: (*autoRedirectHandler)(inbound), + Logger: logger, + NetworkMonitor: networkManager.NetworkMonitor(), + InterfaceFinder: networkManager.InterfaceFinder(), + TableName: "sing-box", + DisableNFTables: dErr == nil && disableNFTables, + RouteAddressSet: &inbound.routeAddressSet, + RouteExcludeAddressSet: &inbound.routeExcludeAddressSet, + }) + if err != nil { + return nil, E.Cause(err, "initialize auto-redirect") + } + if !C.IsAndroid { + inbound.tunOptions.AutoRedirectMarkMode = true + err = networkManager.RegisterAutoRedirectOutputMark(inbound.tunOptions.AutoRedirectOutputMark) + if err != nil { + return nil, err + } + } + } + return inbound, nil +} + +func uidToRange(uidList badoption.Listable[uint32]) []ranges.Range[uint32] { + return common.Map(uidList, func(uid uint32) ranges.Range[uint32] { + return ranges.NewSingle(uid) + }) +} + +func parseRange(uidRanges []ranges.Range[uint32], rangeList []string) ([]ranges.Range[uint32], error) { + for _, uidRange := range rangeList { + if !strings.Contains(uidRange, ":") { + return nil, E.New("missing ':' in range: ", uidRange) + } + subIndex := strings.Index(uidRange, ":") + if subIndex == 0 { + return nil, E.New("missing range start: ", uidRange) + } else if subIndex == len(uidRange)-1 { + return nil, E.New("missing range end: ", uidRange) + } + var start, end uint64 + var err error + start, err = strconv.ParseUint(uidRange[:subIndex], 0, 32) + if err != nil { + return nil, E.Cause(err, "parse range start") + } + end, err = strconv.ParseUint(uidRange[subIndex+1:], 0, 32) + if err != nil { + return nil, E.Cause(err, "parse range end") + } + uidRanges = append(uidRanges, ranges.New(uint32(start), uint32(end))) + } + return uidRanges, nil +} + +func (t *Inbound) Type() string { + return C.TypeTun +} + +func (t *Inbound) Tag() string { + return t.tag +} + +func (t *Inbound) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateStart: + if C.IsAndroid && t.platformInterface == nil { + t.tunOptions.BuildAndroidRules(t.networkManager.PackageManager()) + } + if t.tunOptions.Name == "" { + t.tunOptions.Name = tun.CalculateInterfaceName("") + } + if t.platformInterface == nil { + t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet) + for _, routeRuleSet := range t.routeRuleSet { + ipSets := routeRuleSet.ExtractIPSet() + if len(ipSets) == 0 { + t.logger.Warn("route_address_set: no destination IP CIDR rules found in rule-set: ", routeRuleSet.Name()) + } + routeRuleSet.IncRef() + t.routeAddressSet = append(t.routeAddressSet, ipSets...) + if t.autoRedirect != nil { + t.routeRuleSetCallback = append(t.routeRuleSetCallback, routeRuleSet.RegisterCallback(t.updateRouteAddressSet)) + } + } + t.routeExcludeAddressSet = common.FlatMap(t.routeExcludeRuleSet, adapter.RuleSet.ExtractIPSet) + for _, routeExcludeRuleSet := range t.routeExcludeRuleSet { + ipSets := routeExcludeRuleSet.ExtractIPSet() + if len(ipSets) == 0 { + t.logger.Warn("route_address_set: no destination IP CIDR rules found in rule-set: ", routeExcludeRuleSet.Name()) + } + routeExcludeRuleSet.IncRef() + t.routeExcludeAddressSet = append(t.routeExcludeAddressSet, ipSets...) + if t.autoRedirect != nil { + t.routeExcludeRuleSetCallback = append(t.routeExcludeRuleSetCallback, routeExcludeRuleSet.RegisterCallback(t.updateRouteAddressSet)) + } + } + } + var ( + tunInterface tun.Tun + err error + ) + monitor := taskmonitor.New(t.logger, C.StartTimeout) + tunOptions := t.tunOptions + if t.autoRedirect == nil && !(runtime.GOOS == "android" && t.platformInterface != nil) { + for _, ipSet := range t.routeAddressSet { + for _, prefix := range ipSet.Prefixes() { + if prefix.Addr().Is4() { + tunOptions.Inet4RouteAddress = append(tunOptions.Inet4RouteAddress, prefix) + } else { + tunOptions.Inet6RouteAddress = append(tunOptions.Inet6RouteAddress, prefix) + } + } + } + for _, ipSet := range t.routeExcludeAddressSet { + for _, prefix := range ipSet.Prefixes() { + if prefix.Addr().Is4() { + tunOptions.Inet4RouteExcludeAddress = append(tunOptions.Inet4RouteExcludeAddress, prefix) + } else { + tunOptions.Inet6RouteExcludeAddress = append(tunOptions.Inet6RouteExcludeAddress, prefix) + } + } + } + } + monitor.Start("open interface") + if t.platformInterface != nil && t.platformInterface.UsePlatformInterface() { + tunInterface, err = t.platformInterface.OpenInterface(&tunOptions, t.platformOptions) + } else { + if HookBeforeCreatePlatformInterface != nil { + HookBeforeCreatePlatformInterface() + } + tunInterface, err = tun.New(tunOptions) + } + monitor.Finish() + t.tunOptions.Name = tunOptions.Name + if err != nil { + return E.Cause(err, "configure tun interface") + } + t.logger.Trace("creating stack") + t.tunIf = tunInterface + var ( + forwarderBindInterface bool + includeAllNetworks bool + ) + if t.platformInterface != nil { + forwarderBindInterface = true + includeAllNetworks = t.platformInterface.NetworkExtensionIncludeAllNetworks() + } + tunStack, err := tun.NewStack(t.stack, tun.StackOptions{ + Context: t.ctx, + Tun: tunInterface, + TunOptions: t.tunOptions, + UDPTimeout: t.udpTimeout, + Handler: t, + Logger: t.logger, + ForwarderBindInterface: forwarderBindInterface, + InterfaceFinder: t.networkManager.InterfaceFinder(), + IncludeAllNetworks: includeAllNetworks, + }) + if err != nil { + return err + } + t.tunStack = tunStack + t.logger.Info("started at ", t.tunOptions.Name) + case adapter.StartStatePostStart: + monitor := taskmonitor.New(t.logger, C.StartTimeout) + monitor.Start("starting tun stack") + err := t.tunStack.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "starting tun stack") + } + monitor.Start("starting tun interface") + err = t.tunIf.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "starting TUN interface") + } + if t.autoRedirect != nil { + monitor.Start("initialize auto-redirect") + err := t.autoRedirect.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "auto-redirect") + } + } + t.routeAddressSet = nil + t.routeExcludeAddressSet = nil + } + return nil +} + +func (t *Inbound) updateRouteAddressSet(it adapter.RuleSet) { + t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet) + t.routeExcludeAddressSet = common.FlatMap(t.routeExcludeRuleSet, adapter.RuleSet.ExtractIPSet) + t.autoRedirect.UpdateRouteAddressSet() + t.routeAddressSet = nil + t.routeExcludeAddressSet = nil +} + +func (t *Inbound) Close() error { + return common.Close( + t.tunStack, + t.tunIf, + t.autoRedirect, + ) +} + +func (t *Inbound) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + var ipVersion uint8 + if !destination.IsIPv6() { + ipVersion = 4 + } else { + ipVersion = 6 + } + routeDestination, err := t.router.PreMatch(adapter.InboundContext{ + Inbound: t.tag, + InboundType: C.TypeTun, + IPVersion: ipVersion, + Network: network, + Source: source, + Destination: destination, + }, routeContext, timeout, false) + if err != nil { + switch { + case rule.IsBypassed(err): + err = nil + case rule.IsRejected(err): + t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()) + default: + if network == N.NetworkICMP { + t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())) + } + } + } + return routeDestination, err +} + +func (t *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun + metadata.Source = source + metadata.Destination = destination + + t.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + t.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + t.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (t *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun + metadata.Source = source + metadata.Destination = destination + + t.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) + t.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +type autoRedirectHandler Inbound + +func (t *autoRedirectHandler) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + var ipVersion uint8 + if !destination.IsIPv6() { + ipVersion = 4 + } else { + ipVersion = 6 + } + routeDestination, err := t.router.PreMatch(adapter.InboundContext{ + Inbound: t.tag, + InboundType: C.TypeTun, + IPVersion: ipVersion, + Network: network, + Source: source, + Destination: destination, + }, routeContext, timeout, true) + if err != nil { + switch { + case rule.IsBypassed(err): + t.logger.Trace("bypass ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()) + case rule.IsRejected(err): + t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()) + default: + if network == N.NetworkICMP { + t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())) + } + } + } + return routeDestination, err +} + +func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun + metadata.Source = source + metadata.Destination = destination + + t.logger.InfoContext(ctx, "inbound redirect connection from ", metadata.Source) + t.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + t.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (t *autoRedirectHandler) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + panic("unexcepted") +} diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go new file mode 100644 index 00000000..75cd4124 --- /dev/null +++ b/protocol/vless/inbound.go @@ -0,0 +1,222 @@ +package vless + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing-vmess/packetaddr" + "github.com/sagernet/sing-vmess/vless" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.VLESSInboundOptions](registry, C.TypeVLESS, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + users []option.VLESSUser + service *vless.Service[int] + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeVLESS, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + users: options.Users, + } + var err error + inbound.router, err = mux.NewRouterWithOptions(inbound.router, logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + service := vless.NewService[int](logger, adapter.NewUpstreamContextHandlerEx(inbound.newConnectionEx, inbound.newPacketConnectionEx)) + service.UpdateUsers(common.MapIndexed(inbound.users, func(index int, _ option.VLESSUser) int { + return index + }), common.Map(inbound.users, func(it option.VLESSUser) string { + return it.UUID + }), common.Map(inbound.users, func(it option.VLESSUser) string { + return it.Flow + })) + inbound.service = service + if options.TLS != nil { + inbound.tlsConfig, err = tls.NewServerWithOptions(tls.ServerOptions{ + Context: ctx, + Logger: logger, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: common.PtrValueOrDefault(options.Transport).Type == "" && + !common.PtrValueOrDefault(options.Multiplex).Enabled && + common.All(options.Users, func(it option.VLESSUser) bool { + return it.Flow == "" + }), + }) + if err != nil { + return nil, err + } + } + if options.Transport != nil { + inbound.transport, err = v2ray.NewServerTransport(ctx, logger, common.PtrValueOrDefault(options.Transport), inbound.tlsConfig, (*inboundTransportHandler)(inbound)) + if err != nil { + return nil, E.Cause(err, "create server transport: ", options.Transport.Type) + } + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return err + } + } + if h.transport == nil { + return h.listener.Start() + } + if common.Contains(h.transport.Network(), N.NetworkTCP) { + tcpListener, err := h.listener.ListenTCP() + if err != nil { + return err + } + go func() { + sErr := h.transport.Serve(tcpListener) + if sErr != nil && !E.IsClosed(sErr) { + h.logger.Error("transport serve error: ", sErr) + } + }() + } + if common.Contains(h.transport.Network(), N.NetworkUDP) { + udpConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + go func() { + sErr := h.transport.ServePacket(udpConn) + if sErr != nil && !E.IsClosed(sErr) { + h.logger.Error("transport serve error: ", sErr) + } + }() + } + return nil +} + +func (h *Inbound) Close() error { + return common.Close( + h.service, + h.listener, + h.tlsConfig, + h.transport, + ) +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if h.tlsConfig != nil && h.transport == nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return + } + conn = tlsConn + } + err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } +} + +func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + if metadata.Destination.Fqdn == packetaddr.SeqPacketMagicAddress { + metadata.Destination = M.Socksaddr{} + conn = packetaddr.NewConn(bufio.NewNetPacketConn(conn), metadata.Destination) + h.logger.InfoContext(ctx, "[", user, "] inbound packet addr connection") + } else { + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +var _ adapter.V2RayServerTransportHandler = (*inboundTransportHandler)(nil) + +type inboundTransportHandler Inbound + +func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Source = source + metadata.Destination = destination + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + (*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go new file mode 100644 index 00000000..d8132cf9 --- /dev/null +++ b/protocol/vless/outbound.go @@ -0,0 +1,218 @@ +package vless + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing-vmess/packetaddr" + "github.com/sagernet/sing-vmess/vless" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.VLESSOutboundOptions](registry, C.TypeVLESS, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + dialer N.Dialer + client *vless.Client + serverAddr M.Socksaddr + multiplexDialer *mux.Client + tlsConfig tls.Config + tlsDialer tls.Dialer + transport adapter.V2RayClientTransport + packetAddr bool + xudp bool +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeVLESS, tag, options.Network.Build(), options.DialerOptions), + logger: logger, + dialer: outboundDialer, + serverAddr: options.ServerOptions.Build(), + } + if options.TLS != nil { + outbound.tlsConfig, err = tls.NewClientWithOptions(tls.ClientOptions{ + Context: ctx, + Logger: logger, + ServerAddress: options.Server, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: common.PtrValueOrDefault(options.Transport).Type == "" && + !common.PtrValueOrDefault(options.Multiplex).Enabled && + options.Flow == "", + }) + if err != nil { + return nil, err + } + outbound.tlsDialer = tls.NewDialer(outboundDialer, outbound.tlsConfig) + } + if options.Transport != nil { + outbound.transport, err = v2ray.NewClientTransport(ctx, outbound.dialer, outbound.serverAddr, common.PtrValueOrDefault(options.Transport), outbound.tlsConfig) + if err != nil { + return nil, E.Cause(err, "create client transport: ", options.Transport.Type) + } + } + if options.PacketEncoding == nil { + outbound.xudp = true + } else { + switch *options.PacketEncoding { + case "": + case "packetaddr": + outbound.packetAddr = true + case "xudp": + outbound.xudp = true + default: + return nil, E.New("unknown packet encoding: ", options.PacketEncoding) + } + } + outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger) + if err != nil { + return nil, err + } + outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if h.multiplexDialer == nil { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + return (*vlessDialer)(h).DialContext(ctx, network, destination) + } else { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound multiplex connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + } + return h.multiplexDialer.DialContext(ctx, network, destination) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if h.multiplexDialer == nil { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return (*vlessDialer)(h).ListenPacket(ctx, destination) + } else { + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + return h.multiplexDialer.ListenPacket(ctx, destination) + } +} + +func (h *Outbound) InterfaceUpdated() { + if h.transport != nil { + h.transport.Close() + } + if h.multiplexDialer != nil { + h.multiplexDialer.Reset() + } +} + +func (h *Outbound) Close() error { + return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) +} + +type vlessDialer Outbound + +func (h *vlessDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + var conn net.Conn + var err error + if h.transport != nil { + conn, err = h.transport.DialContext(ctx) + } else if h.tlsDialer != nil { + conn, err = h.tlsDialer.DialTLSContext(ctx, h.serverAddr) + } else { + conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + } + if err != nil { + return nil, err + } + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialEarlyConn(conn, destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if h.xudp { + return h.client.DialEarlyXUDPPacketConn(conn, destination) + } else if h.packetAddr { + if destination.IsDomain() { + return nil, E.New("packetaddr: domain destination is not supported") + } + packetConn, err := h.client.DialEarlyPacketConn(conn, M.Socksaddr{Fqdn: packetaddr.SeqPacketMagicAddress}) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(packetaddr.NewConn(packetConn, destination), destination), nil + } else { + return h.client.DialEarlyPacketConn(conn, destination) + } + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + var conn net.Conn + var err error + if h.transport != nil { + conn, err = h.transport.DialContext(ctx) + } else if h.tlsDialer != nil { + conn, err = h.tlsDialer.DialTLSContext(ctx, h.serverAddr) + } else { + conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + } + if err != nil { + common.Close(conn) + return nil, err + } + if h.xudp { + return h.client.DialEarlyXUDPPacketConn(conn, destination) + } else if h.packetAddr { + if destination.IsDomain() { + return nil, E.New("packetaddr: domain destination is not supported") + } + conn, err := h.client.DialEarlyPacketConn(conn, M.Socksaddr{Fqdn: packetaddr.SeqPacketMagicAddress}) + if err != nil { + return nil, err + } + return packetaddr.NewConn(conn, destination), nil + } else { + return h.client.DialEarlyPacketConn(conn, destination) + } +} diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go new file mode 100644 index 00000000..4e9c763c --- /dev/null +++ b/protocol/vmess/inbound.go @@ -0,0 +1,228 @@ +package vmess + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing-vmess" + "github.com/sagernet/sing-vmess/packetaddr" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.VMessInboundOptions](registry, C.TypeVMess, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service *vmess.Service[int] + users []option.VMessUser + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeVMess, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + users: options.Users, + } + var err error + inbound.router, err = mux.NewRouterWithOptions(inbound.router, logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + var serviceOptions []vmess.ServiceOption + if timeFunc := ntp.TimeFuncFromContext(ctx); timeFunc != nil { + serviceOptions = append(serviceOptions, vmess.ServiceWithTimeFunc(timeFunc)) + } + if options.Transport != nil && options.Transport.Type != "" { + serviceOptions = append(serviceOptions, vmess.ServiceWithDisableHeaderProtection()) + } + service := vmess.NewService[int](adapter.NewUpstreamContextHandlerEx(inbound.newConnectionEx, inbound.newPacketConnectionEx), serviceOptions...) + inbound.service = service + err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.VMessUser) int { + return index + }), common.Map(options.Users, func(it option.VMessUser) string { + return it.UUID + }), common.Map(options.Users, func(it option.VMessUser) int { + return it.AlterId + })) + if err != nil { + return nil, err + } + if options.TLS != nil { + inbound.tlsConfig, err = tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + } + if options.Transport != nil { + inbound.transport, err = v2ray.NewServerTransport(ctx, logger, common.PtrValueOrDefault(options.Transport), inbound.tlsConfig, (*inboundTransportHandler)(inbound)) + if err != nil { + return nil, E.Cause(err, "create server transport: ", options.Transport.Type) + } + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := h.service.Start() + if err != nil { + return err + } + if h.tlsConfig != nil { + err = h.tlsConfig.Start() + if err != nil { + return err + } + } + if h.transport == nil { + return h.listener.Start() + } + if common.Contains(h.transport.Network(), N.NetworkTCP) { + tcpListener, err := h.listener.ListenTCP() + if err != nil { + return err + } + go func() { + sErr := h.transport.Serve(tcpListener) + if sErr != nil && !E.IsClosed(sErr) { + h.logger.Error("transport serve error: ", sErr) + } + }() + } + if common.Contains(h.transport.Network(), N.NetworkUDP) { + udpConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + go func() { + sErr := h.transport.ServePacket(udpConn) + if sErr != nil && !E.IsClosed(sErr) { + h.logger.Error("transport serve error: ", sErr) + } + }() + } + return nil +} + +func (h *Inbound) Close() error { + return common.Close( + h.service, + h.listener, + h.tlsConfig, + h.transport, + ) +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if h.tlsConfig != nil && h.transport == nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return + } + conn = tlsConn + } + err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } +} + +func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + if metadata.Destination.Fqdn == packetaddr.SeqPacketMagicAddress { + metadata.Destination = M.Socksaddr{} + conn = packetaddr.NewConn(bufio.NewNetPacketConn(conn), metadata.Destination) + h.logger.InfoContext(ctx, "[", user, "] inbound packet addr connection") + } else { + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +var _ adapter.V2RayServerTransportHandler = (*inboundTransportHandler)(nil) + +type inboundTransportHandler Inbound + +func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Source = source + metadata.Destination = destination + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + //nolint:staticcheck + h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) + (*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go new file mode 100644 index 00000000..703f06b1 --- /dev/null +++ b/protocol/vmess/outbound.go @@ -0,0 +1,206 @@ +package vmess + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/mux" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing-vmess" + "github.com/sagernet/sing-vmess/packetaddr" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.VMessOutboundOptions](registry, C.TypeVMess, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + dialer N.Dialer + client *vmess.Client + serverAddr M.Socksaddr + multiplexDialer *mux.Client + tlsConfig tls.Config + tlsDialer tls.Dialer + transport adapter.V2RayClientTransport + packetAddr bool + xudp bool +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeVMess, tag, options.Network.Build(), options.DialerOptions), + logger: logger, + dialer: outboundDialer, + serverAddr: options.ServerOptions.Build(), + } + if options.TLS != nil { + outbound.tlsConfig, err = tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + if outbound.tlsConfig != nil { + outbound.tlsDialer = tls.NewDialer(outboundDialer, outbound.tlsConfig) + } + } + if options.Transport != nil { + outbound.transport, err = v2ray.NewClientTransport(ctx, outbound.dialer, outbound.serverAddr, common.PtrValueOrDefault(options.Transport), outbound.tlsConfig) + if err != nil { + return nil, E.Cause(err, "create client transport: ", options.Transport.Type) + } + } + outbound.multiplexDialer, err = mux.NewClientWithOptions((*vmessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + if err != nil { + return nil, err + } + switch options.PacketEncoding { + case "": + case "packetaddr": + outbound.packetAddr = true + case "xudp": + outbound.xudp = true + default: + return nil, E.New("unknown packet encoding: ", options.PacketEncoding) + } + var clientOptions []vmess.ClientOption + if timeFunc := ntp.TimeFuncFromContext(ctx); timeFunc != nil { + clientOptions = append(clientOptions, vmess.ClientWithTimeFunc(timeFunc)) + } + if options.GlobalPadding { + clientOptions = append(clientOptions, vmess.ClientWithGlobalPadding()) + } + if options.AuthenticatedLength { + clientOptions = append(clientOptions, vmess.ClientWithAuthenticatedLength()) + } + security := options.Security + if security == "" { + security = "auto" + } + if security == "auto" && outbound.tlsConfig != nil { + security = "zero" + } + client, err := vmess.NewClient(options.UUID, security, options.AlterId, clientOptions...) + if err != nil { + return nil, err + } + outbound.client = client + return outbound, nil +} + +func (h *Outbound) InterfaceUpdated() { + if h.transport != nil { + h.transport.Close() + } + if h.multiplexDialer != nil { + h.multiplexDialer.Reset() + } +} + +func (h *Outbound) Close() error { + return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if h.multiplexDialer == nil { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + return (*vmessDialer)(h).DialContext(ctx, network, destination) + } else { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound multiplex connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + } + return h.multiplexDialer.DialContext(ctx, network, destination) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if h.multiplexDialer == nil { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return (*vmessDialer)(h).ListenPacket(ctx, destination) + } else { + h.logger.InfoContext(ctx, "outbound multiplex packet connection to ", destination) + return h.multiplexDialer.ListenPacket(ctx, destination) + } +} + +type vmessDialer Outbound + +func (h *vmessDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + var conn net.Conn + var err error + if h.transport != nil { + conn, err = h.transport.DialContext(ctx) + } else if h.tlsDialer != nil { + conn, err = h.tlsDialer.DialTLSContext(ctx, h.serverAddr) + } else { + conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + } + if err != nil { + common.Close(conn) + return nil, err + } + switch N.NetworkName(network) { + case N.NetworkTCP: + return h.client.DialEarlyConn(conn, destination), nil + case N.NetworkUDP: + return h.client.DialEarlyPacketConn(conn, destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *vmessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + var conn net.Conn + var err error + if h.transport != nil { + conn, err = h.transport.DialContext(ctx) + } else if h.tlsDialer != nil { + conn, err = h.tlsDialer.DialTLSContext(ctx, h.serverAddr) + } else { + conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + } + if err != nil { + return nil, err + } + if h.packetAddr { + if destination.IsDomain() { + return nil, E.New("packetaddr: domain destination is not supported") + } + return packetaddr.NewConn(h.client.DialEarlyPacketConn(conn, M.Socksaddr{Fqdn: packetaddr.SeqPacketMagicAddress}), destination), nil + } else if h.xudp { + return h.client.DialEarlyXUDPPacketConn(conn, destination), nil + } else { + return h.client.DialEarlyPacketConn(conn, destination), nil + } +} diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go new file mode 100644 index 00000000..9fdc4814 --- /dev/null +++ b/protocol/wireguard/endpoint.go @@ -0,0 +1,265 @@ +package wireguard + +import ( + "context" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-box/transport/wireguard" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) +) + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.WireGuardEndpointOptions](registry, C.TypeWireGuard, NewEndpoint) +} + +type Endpoint struct { + endpoint.Adapter + ctx context.Context + router adapter.Router + dnsRouter adapter.DNSRouter + logger logger.ContextLogger + localAddresses []netip.Prefix + endpoint *wireguard.Endpoint +} + +func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WireGuardEndpointOptions) (adapter.Endpoint, error) { + ep := &Endpoint{ + Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeWireGuard, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + ctx: ctx, + router: router, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + logger: logger, + localAddresses: options.Address, + } + if options.Detour != "" && options.ListenPort != 0 { + return nil, E.New("`listen_port` is conflict with `detour`") + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: common.Any(options.Peers, func(it option.WireGuardPeer) bool { + return !M.ParseAddr(it.Address).IsValid() + }), + ResolverOnDetour: true, + }) + if err != nil { + return nil, err + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: ep, + UDPTimeout: udpTimeout, + Dialer: outboundDialer, + CreateDialer: func(interfaceName string) N.Dialer { + return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ + BindInterface: interfaceName, + })) + }, + Name: options.Name, + MTU: options.MTU, + Address: options.Address, + PrivateKey: options.PrivateKey, + ListenPort: options.ListenPort, + ResolvePeer: func(domain string) (netip.Addr, error) { + endpointAddresses, lookupErr := ep.dnsRouter.Lookup(ctx, domain, outboundDialer.(dialer.ResolveDialer).QueryOptions()) + if lookupErr != nil { + return netip.Addr{}, lookupErr + } + return endpointAddresses[0], nil + }, + Peers: common.Map(options.Peers, func(it option.WireGuardPeer) wireguard.PeerOptions { + return wireguard.PeerOptions{ + Endpoint: M.ParseSocksaddrHostPort(it.Address, it.Port), + PublicKey: it.PublicKey, + PreSharedKey: it.PreSharedKey, + AllowedIPs: it.AllowedIPs, + PersistentKeepaliveInterval: it.PersistentKeepaliveInterval, + Reserved: it.Reserved, + } + }), + Workers: options.Workers, + }) + if err != nil { + return nil, err + } + ep.endpoint = wgEndpoint + return ep, nil +} + +func (w *Endpoint) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateStart: + return w.endpoint.Start(false) + case adapter.StartStatePostStart: + return w.endpoint.Start(true) + } + return nil +} + +func (w *Endpoint) Close() error { + return w.endpoint.Close() +} + +func (w *Endpoint) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + var ipVersion uint8 + if !destination.IsIPv6() { + ipVersion = 4 + } else { + ipVersion = 6 + } + routeDestination, err := w.router.PreMatch(adapter.InboundContext{ + Inbound: w.Tag(), + InboundType: w.Type(), + IPVersion: ipVersion, + Network: network, + Source: source, + Destination: destination, + }, routeContext, timeout, false) + if err != nil { + switch { + case rule.IsBypassed(err): + err = nil + case rule.IsRejected(err): + w.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()) + default: + if network == N.NetworkICMP { + w.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())) + } + } + } + return routeDestination, err +} + +func (w *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = w.Tag() + metadata.InboundType = w.Type() + metadata.Source = source + for _, localPrefix := range w.localAddresses { + if localPrefix.Contains(destination.Addr) { + metadata.OriginDestination = destination + if destination.Addr.Is4() { + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } else { + destination.Addr = netip.IPv6Loopback() + } + break + } + } + metadata.Destination = destination + w.logger.InfoContext(ctx, "inbound connection from ", source) + w.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + w.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (w *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = w.Tag() + metadata.InboundType = w.Type() + metadata.Source = source + metadata.Destination = destination + for _, localPrefix := range w.localAddresses { + if localPrefix.Contains(destination.Addr) { + metadata.OriginDestination = destination + if destination.Addr.Is4() { + metadata.Destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } else { + metadata.Destination.Addr = netip.IPv6Loopback() + } + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination) + } + } + w.logger.InfoContext(ctx, "inbound packet connection from ", source) + w.logger.InfoContext(ctx, "inbound packet connection to ", destination) + w.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (w *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + w.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + w.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if destination.IsDomain() { + destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, w.endpoint, network, destination, destinationAddresses) + } else if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return w.endpoint.DialContext(ctx, network, destination) +} + +func (w *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + w.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if destination.IsDomain() { + destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, w.endpoint, destination, destinationAddresses) + } + packetConn, err := w.endpoint.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (w *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := w.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (w *Endpoint) PreferredDomain(domain string) bool { + return false +} + +func (w *Endpoint) PreferredAddress(address netip.Addr) bool { + return w.endpoint.Lookup(address) != nil +} + +func (w *Endpoint) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + return w.endpoint.NewDirectRouteConnection(metadata, routeContext, timeout) +} diff --git a/release/DEFAULT_BUILD_TAGS b/release/DEFAULT_BUILD_TAGS new file mode 100644 index 00000000..e06bc120 --- /dev/null +++ b/release/DEFAULT_BUILD_TAGS @@ -0,0 +1 @@ +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,badlinkname,tfogo_checklinkname0 \ No newline at end of file diff --git a/release/DEFAULT_BUILD_TAGS_OTHERS b/release/DEFAULT_BUILD_TAGS_OTHERS new file mode 100644 index 00000000..a28e900e --- /dev/null +++ b/release/DEFAULT_BUILD_TAGS_OTHERS @@ -0,0 +1 @@ +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,badlinkname,tfogo_checklinkname0 \ No newline at end of file diff --git a/release/DEFAULT_BUILD_TAGS_WINDOWS b/release/DEFAULT_BUILD_TAGS_WINDOWS new file mode 100644 index 00000000..af4fe416 --- /dev/null +++ b/release/DEFAULT_BUILD_TAGS_WINDOWS @@ -0,0 +1 @@ +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0 \ No newline at end of file diff --git a/release/LDFLAGS b/release/LDFLAGS new file mode 100644 index 00000000..8f613f97 --- /dev/null +++ b/release/LDFLAGS @@ -0,0 +1 @@ +-X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0 \ No newline at end of file diff --git a/release/completions/sing-box.bash b/release/completions/sing-box.bash new file mode 100644 index 00000000..22df7763 --- /dev/null +++ b/release/completions/sing-box.bash @@ -0,0 +1,1580 @@ +# bash completion for sing-box -*- shell-script -*- + +__sing-box_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Homebrew on Macs have version 1.3 of bash-completion which doesn't include +# _init_completion. This is a very minimal version of that function. +__sing-box_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +__sing-box_index_of_word() +{ + local w word=$1 + shift + index=0 + for w in "$@"; do + [[ $w = "$word" ]] && return + index=$((index+1)) + done + index=-1 +} + +__sing-box_contains_word() +{ + local w word=$1; shift + for w in "$@"; do + [[ $w = "$word" ]] && return + done + return 1 +} + +__sing-box_handle_go_custom_completion() +{ + __sing-box_debug "${FUNCNAME[0]}: cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}" + + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + + local out requestComp lastParam lastChar comp directive args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly sing-box allows handling aliases + args=("${words[@]:1}") + # Disable ActiveHelp which is not supported for bash completion v1 + requestComp="SING_BOX_ACTIVE_HELP=0 ${words[0]} __completeNoDesc ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __sing-box_debug "${FUNCNAME[0]}: lastParam ${lastParam}, lastChar ${lastChar}" + + if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __sing-box_debug "${FUNCNAME[0]}: Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __sing-box_debug "${FUNCNAME[0]}: calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [ "${directive}" = "${out}" ]; then + # There is not directive specified + directive=0 + fi + __sing-box_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" + __sing-box_debug "${FUNCNAME[0]}: the completions are: ${out}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + # Error code. No completion. + __sing-box_debug "${FUNCNAME[0]}: received error from custom completion go code" + return + else + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __sing-box_debug "${FUNCNAME[0]}: activating no space" + compopt -o nospace + fi + fi + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __sing-box_debug "${FUNCNAME[0]}: activating no file completion" + compopt +o default + fi + fi + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local fullFilter filter filteringCmd + # Do not use quotes around the $out variable or else newline + # characters will be kept. + for filter in ${out}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __sing-box_debug "File filtering command: $filteringCmd" + $filteringCmd + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + # Use printf to strip any trailing newline + subdir=$(printf "%s" "${out}") + if [ -n "$subdir" ]; then + __sing-box_debug "Listing directories in $subdir" + __sing-box_handle_subdirs_in_dir_flag "$subdir" + else + __sing-box_debug "Listing directories in ." + _filedir -d + fi + else + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${out}" -- "$cur") + fi +} + +__sing-box_handle_reply() +{ + __sing-box_debug "${FUNCNAME[0]}" + local comp + case $cur in + -*) + if [[ $(type -t compopt) = "builtin" ]]; then + compopt -o nospace + fi + local allflags + if [ ${#must_have_one_flag[@]} -ne 0 ]; then + allflags=("${must_have_one_flag[@]}") + else + allflags=("${flags[*]} ${two_word_flags[*]}") + fi + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${allflags[*]}" -- "$cur") + if [[ $(type -t compopt) = "builtin" ]]; then + [[ "${COMPREPLY[0]}" == *= ]] || compopt +o nospace + fi + + # complete after --flag=abc + if [[ $cur == *=* ]]; then + if [[ $(type -t compopt) = "builtin" ]]; then + compopt +o nospace + fi + + local index flag + flag="${cur%=*}" + __sing-box_index_of_word "${flag}" "${flags_with_completion[@]}" + COMPREPLY=() + if [[ ${index} -ge 0 ]]; then + PREFIX="" + cur="${cur#*=}" + ${flags_completion[${index}]} + if [ -n "${ZSH_VERSION:-}" ]; then + # zsh completion needs --flag= prefix + eval "COMPREPLY=( \"\${COMPREPLY[@]/#/${flag}=}\" )" + fi + fi + fi + + if [[ -z "${flag_parsing_disabled}" ]]; then + # If flag parsing is enabled, we have completed the flags and can return. + # If flag parsing is disabled, we may not know all (or any) of the flags, so we fallthrough + # to possibly call handle_go_custom_completion. + return 0; + fi + ;; + esac + + # check if we are handling a flag with special work handling + local index + __sing-box_index_of_word "${prev}" "${flags_with_completion[@]}" + if [[ ${index} -ge 0 ]]; then + ${flags_completion[${index}]} + return + fi + + # we are parsing a flag and don't have a special handler, no completion + if [[ ${cur} != "${words[cword]}" ]]; then + return + fi + + local completions + completions=("${commands[@]}") + if [[ ${#must_have_one_noun[@]} -ne 0 ]]; then + completions+=("${must_have_one_noun[@]}") + elif [[ -n "${has_completion_function}" ]]; then + # if a go completion function is provided, defer to that function + __sing-box_handle_go_custom_completion + fi + if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then + completions+=("${must_have_one_flag[@]}") + fi + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${completions[*]}" -- "$cur") + + if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${noun_aliases[*]}" -- "$cur") + fi + + if [[ ${#COMPREPLY[@]} -eq 0 ]]; then + if declare -F __sing-box_custom_func >/dev/null; then + # try command name qualified custom func + __sing-box_custom_func + else + # otherwise fall back to unqualified for compatibility + declare -F __custom_func >/dev/null && __custom_func + fi + fi + + # available in bash-completion >= 2, not always present on macOS + if declare -F __ltrim_colon_completions >/dev/null; then + __ltrim_colon_completions "$cur" + fi + + # If there is only 1 completion and it is a flag with an = it will be completed + # but we don't want a space after the = + if [[ "${#COMPREPLY[@]}" -eq "1" ]] && [[ $(type -t compopt) = "builtin" ]] && [[ "${COMPREPLY[0]}" == --*= ]]; then + compopt -o nospace + fi +} + +# The arguments should be in the form "ext1|ext2|extn" +__sing-box_handle_filename_extension_flag() +{ + local ext="$1" + _filedir "@(${ext})" +} + +__sing-box_handle_subdirs_in_dir_flag() +{ + local dir="$1" + pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return +} + +__sing-box_handle_flag() +{ + __sing-box_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + # if a command required a flag, and we found it, unset must_have_one_flag() + local flagname=${words[c]} + local flagvalue="" + # if the word contained an = + if [[ ${words[c]} == *"="* ]]; then + flagvalue=${flagname#*=} # take in as flagvalue after the = + flagname=${flagname%=*} # strip everything after the = + flagname="${flagname}=" # but put the = back + fi + __sing-box_debug "${FUNCNAME[0]}: looking for ${flagname}" + if __sing-box_contains_word "${flagname}" "${must_have_one_flag[@]}"; then + must_have_one_flag=() + fi + + # if you set a flag which only applies to this command, don't show subcommands + if __sing-box_contains_word "${flagname}" "${local_nonpersistent_flags[@]}"; then + commands=() + fi + + # keep flag value with flagname as flaghash + # flaghash variable is an associative array which is only supported in bash > 3. + if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then + if [ -n "${flagvalue}" ] ; then + flaghash[${flagname}]=${flagvalue} + elif [ -n "${words[ $((c+1)) ]}" ] ; then + flaghash[${flagname}]=${words[ $((c+1)) ]} + else + flaghash[${flagname}]="true" # pad "true" for bool flag + fi + fi + + # skip the argument to a two word flag + if [[ ${words[c]} != *"="* ]] && __sing-box_contains_word "${words[c]}" "${two_word_flags[@]}"; then + __sing-box_debug "${FUNCNAME[0]}: found a flag ${words[c]}, skip the next argument" + c=$((c+1)) + # if we are looking for a flags value, don't show commands + if [[ $c -eq $cword ]]; then + commands=() + fi + fi + + c=$((c+1)) + +} + +__sing-box_handle_noun() +{ + __sing-box_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + if __sing-box_contains_word "${words[c]}" "${must_have_one_noun[@]}"; then + must_have_one_noun=() + elif __sing-box_contains_word "${words[c]}" "${noun_aliases[@]}"; then + must_have_one_noun=() + fi + + nouns+=("${words[c]}") + c=$((c+1)) +} + +__sing-box_handle_command() +{ + __sing-box_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + local next_command + if [[ -n ${last_command} ]]; then + next_command="_${last_command}_${words[c]//:/__}" + else + if [[ $c -eq 0 ]]; then + next_command="_sing-box_root_command" + else + next_command="_${words[c]//:/__}" + fi + fi + c=$((c+1)) + __sing-box_debug "${FUNCNAME[0]}: looking for ${next_command}" + declare -F "$next_command" >/dev/null && $next_command +} + +__sing-box_handle_word() +{ + if [[ $c -ge $cword ]]; then + __sing-box_handle_reply + return + fi + __sing-box_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + if [[ "${words[c]}" == -* ]]; then + __sing-box_handle_flag + elif __sing-box_contains_word "${words[c]}" "${commands[@]}"; then + __sing-box_handle_command + elif [[ $c -eq 0 ]]; then + __sing-box_handle_command + elif __sing-box_contains_word "${words[c]}" "${command_aliases[@]}"; then + # aliashash variable is an associative array which is only supported in bash > 3. + if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then + words[c]=${aliashash[${words[c]}]} + __sing-box_handle_command + else + __sing-box_handle_noun + fi + else + __sing-box_handle_noun + fi + __sing-box_handle_word +} + +_sing-box_check() +{ + last_command="sing-box_check" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_format() +{ + last_command="sing-box_format" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--write") + flags+=("-w") + local_nonpersistent_flags+=("--write") + local_nonpersistent_flags+=("-w") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_ech-keypair() +{ + last_command="sing-box_generate_ech-keypair" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--pq-signature-schemes-enabled") + local_nonpersistent_flags+=("--pq-signature-schemes-enabled") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_rand() +{ + last_command="sing-box_generate_rand" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--base64") + local_nonpersistent_flags+=("--base64") + flags+=("--hex") + local_nonpersistent_flags+=("--hex") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_reality-keypair() +{ + last_command="sing-box_generate_reality-keypair" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_tls-keypair() +{ + last_command="sing-box_generate_tls-keypair" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--months=") + two_word_flags+=("--months") + two_word_flags+=("-m") + local_nonpersistent_flags+=("--months") + local_nonpersistent_flags+=("--months=") + local_nonpersistent_flags+=("-m") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_uuid() +{ + last_command="sing-box_generate_uuid" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_vapid-keypair() +{ + last_command="sing-box_generate_vapid-keypair" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate_wg-keypair() +{ + last_command="sing-box_generate_wg-keypair" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_generate() +{ + last_command="sing-box_generate" + + command_aliases=() + + commands=() + commands+=("ech-keypair") + commands+=("rand") + commands+=("reality-keypair") + commands+=("tls-keypair") + commands+=("uuid") + commands+=("vapid-keypair") + commands+=("wg-keypair") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geoip_export() +{ + last_command="sing-box_geoip_export" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--output=") + two_word_flags+=("--output") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--output") + local_nonpersistent_flags+=("--output=") + local_nonpersistent_flags+=("-o") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geoip_list() +{ + last_command="sing-box_geoip_list" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geoip_lookup() +{ + last_command="sing-box_geoip_lookup" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geoip() +{ + last_command="sing-box_geoip" + + command_aliases=() + + commands=() + commands+=("export") + commands+=("list") + commands+=("lookup") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geosite_export() +{ + last_command="sing-box_geosite_export" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--output=") + two_word_flags+=("--output") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--output") + local_nonpersistent_flags+=("--output=") + local_nonpersistent_flags+=("-o") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geosite_list() +{ + last_command="sing-box_geosite_list" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geosite_lookup() +{ + last_command="sing-box_geosite_lookup" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_geosite() +{ + last_command="sing-box_geosite" + + command_aliases=() + + commands=() + commands+=("export") + commands+=("list") + commands+=("lookup") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--file=") + two_word_flags+=("--file") + two_word_flags+=("-f") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_merge() +{ + last_command="sing-box_merge" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_compile() +{ + last_command="sing-box_rule-set_compile" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--output=") + two_word_flags+=("--output") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--output") + local_nonpersistent_flags+=("--output=") + local_nonpersistent_flags+=("-o") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_convert() +{ + last_command="sing-box_rule-set_convert" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--output=") + two_word_flags+=("--output") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--output") + local_nonpersistent_flags+=("--output=") + local_nonpersistent_flags+=("-o") + flags+=("--type=") + two_word_flags+=("--type") + two_word_flags+=("-t") + local_nonpersistent_flags+=("--type") + local_nonpersistent_flags+=("--type=") + local_nonpersistent_flags+=("-t") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_decompile() +{ + last_command="sing-box_rule-set_decompile" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--output=") + two_word_flags+=("--output") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--output") + local_nonpersistent_flags+=("--output=") + local_nonpersistent_flags+=("-o") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_format() +{ + last_command="sing-box_rule-set_format" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--write") + flags+=("-w") + local_nonpersistent_flags+=("--write") + local_nonpersistent_flags+=("-w") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_match() +{ + last_command="sing-box_rule-set_match" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--format=") + two_word_flags+=("--format") + two_word_flags+=("-f") + local_nonpersistent_flags+=("--format") + local_nonpersistent_flags+=("--format=") + local_nonpersistent_flags+=("-f") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_merge() +{ + last_command="sing-box_rule-set_merge" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set_upgrade() +{ + last_command="sing-box_rule-set_upgrade" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--write") + flags+=("-w") + local_nonpersistent_flags+=("--write") + local_nonpersistent_flags+=("-w") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_rule-set() +{ + last_command="sing-box_rule-set" + + command_aliases=() + + commands=() + commands+=("compile") + commands+=("convert") + commands+=("decompile") + commands+=("format") + commands+=("match") + commands+=("merge") + commands+=("upgrade") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_run() +{ + last_command="sing-box_run" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_tools_connect() +{ + last_command="sing-box_tools_connect" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--network=") + two_word_flags+=("--network") + two_word_flags+=("-n") + local_nonpersistent_flags+=("--network") + local_nonpersistent_flags+=("--network=") + local_nonpersistent_flags+=("-n") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--outbound=") + two_word_flags+=("--outbound") + two_word_flags+=("-o") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_tools_fetch() +{ + last_command="sing-box_tools_fetch" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--outbound=") + two_word_flags+=("--outbound") + two_word_flags+=("-o") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_tools_synctime() +{ + last_command="sing-box_tools_synctime" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--format=") + two_word_flags+=("--format") + two_word_flags+=("-f") + local_nonpersistent_flags+=("--format") + local_nonpersistent_flags+=("--format=") + local_nonpersistent_flags+=("-f") + flags+=("--server=") + two_word_flags+=("--server") + two_word_flags+=("-s") + local_nonpersistent_flags+=("--server") + local_nonpersistent_flags+=("--server=") + local_nonpersistent_flags+=("-s") + flags+=("--write") + flags+=("-w") + local_nonpersistent_flags+=("--write") + local_nonpersistent_flags+=("-w") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + flags+=("--outbound=") + two_word_flags+=("--outbound") + two_word_flags+=("-o") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_tools() +{ + last_command="sing-box_tools" + + command_aliases=() + + commands=() + commands+=("connect") + commands+=("fetch") + commands+=("synctime") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--outbound=") + two_word_flags+=("--outbound") + two_word_flags+=("-o") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_version() +{ + last_command="sing-box_version" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--name") + flags+=("-n") + local_nonpersistent_flags+=("--name") + local_nonpersistent_flags+=("-n") + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_sing-box_root_command() +{ + last_command="sing-box" + + command_aliases=() + + commands=() + commands+=("check") + commands+=("format") + commands+=("generate") + commands+=("geoip") + commands+=("geosite") + commands+=("merge") + commands+=("rule-set") + commands+=("run") + commands+=("tools") + commands+=("version") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--config=") + two_word_flags+=("--config") + two_word_flags+=("-c") + flags+=("--config-directory=") + two_word_flags+=("--config-directory") + two_word_flags+=("-C") + flags+=("--directory=") + two_word_flags+=("--directory") + two_word_flags+=("-D") + flags+=("--disable-color") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +__start_sing-box() +{ + local cur prev words cword split + declare -A flaghash 2>/dev/null || : + declare -A aliashash 2>/dev/null || : + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -s || return + else + __sing-box_init_completion -n "=" || return + fi + + local c=0 + local flag_parsing_disabled= + local flags=() + local two_word_flags=() + local local_nonpersistent_flags=() + local flags_with_completion=() + local flags_completion=() + local commands=("sing-box") + local command_aliases=() + local must_have_one_flag=() + local must_have_one_noun=() + local has_completion_function="" + local last_command="" + local nouns=() + local noun_aliases=() + + __sing-box_handle_word +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_sing-box sing-box +else + complete -o default -o nospace -F __start_sing-box sing-box +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/release/completions/sing-box.fish b/release/completions/sing-box.fish new file mode 100644 index 00000000..2aec1e1d --- /dev/null +++ b/release/completions/sing-box.fish @@ -0,0 +1,235 @@ +# fish completion for sing-box -*- shell-script -*- + +function __sing_box_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __sing_box_perform_completion + __sing_box_debug "Starting __sing_box_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __sing_box_debug "args: $args" + __sing_box_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "SING_BOX_ACTIVE_HELP=0 $args[1] __complete $args[2..-1] $lastArg" + + __sing_box_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __sing_box_debug "Comps: $comps" + __sing_box_debug "DirectiveLine: $directiveLine" + __sing_box_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# this function limits calls to __sing_box_perform_completion, by caching the result behind $__sing_box_perform_completion_once_result +function __sing_box_perform_completion_once + __sing_box_debug "Starting __sing_box_perform_completion_once" + + if test -n "$__sing_box_perform_completion_once_result" + __sing_box_debug "Seems like a valid result already exists, skipping __sing_box_perform_completion" + return 0 + end + + set --global __sing_box_perform_completion_once_result (__sing_box_perform_completion) + if test -z "$__sing_box_perform_completion_once_result" + __sing_box_debug "No completions, probably due to a failure" + return 1 + end + + __sing_box_debug "Performed completions and set __sing_box_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__sing_box_perform_completion_once_result variable after completions are run +function __sing_box_clear_perform_completion_once_result + __sing_box_debug "" + __sing_box_debug "========= clearing previously set __sing_box_perform_completion_once_result variable ==========" + set --erase __sing_box_perform_completion_once_result + __sing_box_debug "Successfully erased the variable __sing_box_perform_completion_once_result" +end + +function __sing_box_requires_order_preservation + __sing_box_debug "" + __sing_box_debug "========= checking if order preservation is required ==========" + + __sing_box_perform_completion_once + if test -z "$__sing_box_perform_completion_once_result" + __sing_box_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__sing_box_perform_completion_once_result[-1]) + __sing_box_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __sing_box_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __sing_box_debug "This does require order preservation" + return 0 + end + + __sing_box_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __sing_box_comp_results +# - Return false if file completion should be performed +function __sing_box_prepare_completions + __sing_box_debug "" + __sing_box_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __sing_box_comp_results + + __sing_box_perform_completion_once + __sing_box_debug "Completion results: $__sing_box_perform_completion_once_result" + + if test -z "$__sing_box_perform_completion_once_result" + __sing_box_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__sing_box_perform_completion_once_result[-1]) + set --global __sing_box_comp_results $__sing_box_perform_completion_once_result[1..-2] + + __sing_box_debug "Completions are: $__sing_box_comp_results" + __sing_box_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __sing_box_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __sing_box_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __sing_box_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __sing_box_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__sing_box_comp_results) + set --global __sing_box_comp_results $completions + __sing_box_debug "Filtered completions are: $__sing_box_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__sing_box_comp_results) + __sing_box_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \t $__sing_box_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __sing_box_debug "Adding second completion to perform nospace directive" + set --global __sing_box_comp_results $split[1] $split[1]. + __sing_box_debug "Completions are now: $__sing_box_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __sing_box_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "sing-box" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "sing-box " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c sing-box -e + +# this will get called after the two calls below and clear the $__sing_box_perform_completion_once_result global +complete -c sing-box -n '__sing_box_clear_perform_completion_once_result' +# The call to __sing_box_prepare_completions will setup __sing_box_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c sing-box -n 'not __sing_box_requires_order_preservation && __sing_box_prepare_completions' -f -a '$__sing_box_comp_results' +# otherwise we use the -k flag +complete -k -c sing-box -n '__sing_box_requires_order_preservation && __sing_box_prepare_completions' -f -a '$__sing_box_comp_results' diff --git a/release/completions/sing-box.zsh b/release/completions/sing-box.zsh new file mode 100644 index 00000000..96200556 --- /dev/null +++ b/release/completions/sing-box.zsh @@ -0,0 +1,212 @@ +#compdef sing-box +compdef _sing-box sing-box + +# zsh completion for sing-box -*- shell-script -*- + +__sing-box_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_sing-box() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __sing-box_debug "\n========= starting completion logic ==========" + __sing-box_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __sing-box_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __sing-box_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., sing-box -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __sing-box_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __sing-box_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __sing-box_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __sing-box_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __sing-box_debug "No directive found. Setting do default" + directive=0 + fi + + __sing-box_debug "directive: ${directive}" + __sing-box_debug "completions: ${out}" + __sing-box_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __sing-box_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __sing-box_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __sing-box_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __sing-box_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __sing-box_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __sing-box_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __sing-box_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __sing-box_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __sing-box_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __sing-box_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __sing-box_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __sing-box_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __sing-box_debug "_describe did not find completions." + __sing-box_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __sing-box_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __sing-box_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_sing-box" ]; then + _sing-box +fi diff --git a/release/config/config.json b/release/config/config.json new file mode 100644 index 00000000..e5a2a663 --- /dev/null +++ b/release/config/config.json @@ -0,0 +1,41 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "tls", + "tag": "google", + "server": "8.8.8.8" + } + ] + }, + "inbounds": [ + { + "type": "shadowsocks", + "listen": "::", + "listen_port": 8080, + "network": "tcp", + "method": "2022-blake3-aes-128-gcm", + "password": "Gn1JUS14bLUHgv1cWDDp4A==", + "multiplex": { + "enabled": true, + "padding": true + } + } + ], + "outbounds": [ + { + "type": "direct" + } + ], + "route": { + "rules": [ + { + "port": 53, + "action": "hijack-dns" + } + ] + } +} diff --git a/release/config/openwrt.conf b/release/config/openwrt.conf new file mode 100644 index 00000000..1ce4c77d --- /dev/null +++ b/release/config/openwrt.conf @@ -0,0 +1,5 @@ +config sing-box 'main' + option enabled '1' + option conffile '/etc/sing-box/config.json' + option workdir '/usr/share/sing-box' + option log_stderr '1' diff --git a/release/config/openwrt.init b/release/config/openwrt.init new file mode 100644 index 00000000..9979fc1a --- /dev/null +++ b/release/config/openwrt.init @@ -0,0 +1,32 @@ +#!/bin/sh /etc/rc.common + +USE_PROCD=1 +START=99 +PROG="/usr/bin/sing-box" + +start_service() { + config_load "sing-box" + + local enabled config_file working_directory + local log_stderr + config_get_bool enabled "main" "enabled" "0" + [ "$enabled" -eq "1" ] || return 0 + + config_get config_file "main" "conffile" "/etc/sing-box/config.json" + config_get working_directory "main" "workdir" "/usr/share/sing-box" + config_get_bool log_stderr "main" "log_stderr" "1" + + procd_open_instance + procd_set_param command "$PROG" run -c "$config_file" -D "$working_directory" + procd_set_param file "$config_file" + procd_set_param stderr "$log_stderr" + procd_set_param limits core="unlimited" + procd_set_param limits nofile="1000000 1000000" + procd_set_param respawn + + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "sing-box" +} diff --git a/release/config/openwrt.keep b/release/config/openwrt.keep new file mode 100644 index 00000000..b26dd531 --- /dev/null +++ b/release/config/openwrt.keep @@ -0,0 +1 @@ +/etc/sing-box/ diff --git a/release/config/openwrt.prerm b/release/config/openwrt.prerm new file mode 100644 index 00000000..12d06ec7 --- /dev/null +++ b/release/config/openwrt.prerm @@ -0,0 +1,4 @@ +#!/bin/sh +[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0 +. ${IPKG_INSTROOT}/lib/functions.sh +default_prerm $0 $@ diff --git a/release/config/sing-box-split-dns.xml b/release/config/sing-box-split-dns.xml new file mode 100644 index 00000000..4ee64c8d --- /dev/null +++ b/release/config/sing-box-split-dns.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/release/config/sing-box.confd b/release/config/sing-box.confd new file mode 100644 index 00000000..506caa32 --- /dev/null +++ b/release/config/sing-box.confd @@ -0,0 +1,6 @@ +# /etc/conf.d/sing-box: config file for /etc/init.d/sing-box + +# sing-box configuration path, could be file or directory +# SINGBOX_CONFIG=/etc/sing-box + +# SINGBOX_WORKDIR=/var/lib/sing-box diff --git a/release/config/sing-box.initd b/release/config/sing-box.initd new file mode 100644 index 00000000..1541518a --- /dev/null +++ b/release/config/sing-box.initd @@ -0,0 +1,44 @@ +#!/sbin/openrc-run + +name=$RC_SVCNAME +description="sing-box service" +supervisor="supervise-daemon" +command="/usr/bin/sing-box" +extra_commands="checkconfig" +extra_started_commands="reload" + +: ${SINGBOX_CONFIG:=${config:-"/etc/sing-box"}} + +if [ -d "$SINGBOX_CONFIG" ]; then + _config_opt="-C $SINGBOX_CONFIG" +elif [ -z "$SINGBOX_CONFIG" ]; then + _config_opt="" +else + _config_opt="-c $SINGBOX_CONFIG" +fi + +_workdir=${SINGBOX_WORKDIR:-${workdir:-"/var/lib/sing-box"}} + +command_args="run --disable-color + -D $_workdir + $_config_opt" + +depend() { + after net dns +} + +checkconfig() { + ebegin "Checking $RC_SVCNAME configuration" + sing-box check -D "$_workdir" $_config_opt + eend $? +} + +start_pre() { + checkconfig +} + +reload() { + ebegin "Reloading $RC_SVCNAME" + checkconfig && $supervisor "$RC_SVCNAME" --signal HUP + eend $? +} diff --git a/release/config/sing-box.postinst b/release/config/sing-box.postinst new file mode 100644 index 00000000..770dd22a --- /dev/null +++ b/release/config/sing-box.postinst @@ -0,0 +1,3 @@ +#!/bin/sh + +systemd-sysusers sing-box.conf diff --git a/release/config/sing-box.rules b/release/config/sing-box.rules new file mode 100644 index 00000000..668b2640 --- /dev/null +++ b/release/config/sing-box.rules @@ -0,0 +1,8 @@ +polkit.addRule(function(action, subject) { + if ((action.id == "org.freedesktop.resolve1.set-domains" || + action.id == "org.freedesktop.resolve1.set-default-route" || + action.id == "org.freedesktop.resolve1.set-dns-servers") && + subject.user == "sing-box") { + return polkit.Result.YES; + } +}); diff --git a/release/config/sing-box.service b/release/config/sing-box.service new file mode 100644 index 00000000..f003f844 --- /dev/null +++ b/release/config/sing-box.service @@ -0,0 +1,18 @@ +[Unit] +Description=sing-box service +Documentation=https://sing-box.sagernet.org +After=network.target nss-lookup.target network-online.target + +[Service] +User=sing-box +StateDirectory=sing-box +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +ExecStart=/usr/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/release/config/sing-box.sysusers b/release/config/sing-box.sysusers new file mode 100644 index 00000000..33e38100 --- /dev/null +++ b/release/config/sing-box.sysusers @@ -0,0 +1 @@ +u sing-box - "sing-box Service" diff --git a/release/config/sing-box@.service b/release/config/sing-box@.service new file mode 100644 index 00000000..726bbee7 --- /dev/null +++ b/release/config/sing-box@.service @@ -0,0 +1,18 @@ +[Unit] +Description=sing-box service +Documentation=https://sing-box.sagernet.org +After=network.target nss-lookup.target network-online.target + +[Service] +User=sing-box +StateDirectory=sing-box-%i +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +ExecStart=/usr/bin/sing-box -D /var/lib/sing-box-%i -c /etc/sing-box/%i.json run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/release/local/common.sh b/release/local/common.sh new file mode 100644 index 00000000..13a8415c --- /dev/null +++ b/release/local/common.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +BINARY_NAME="sing-box" + +INSTALL_BIN_PATH="/usr/local/bin" +INSTALL_CONFIG_PATH="/usr/local/etc/sing-box" +INSTALL_DATA_PATH="/var/lib/sing-box" +SYSTEMD_SERVICE_PATH="/etc/systemd/system" + +DEFAULT_BUILD_TAGS="$(cat "$PROJECT_DIR/release/DEFAULT_BUILD_TAGS_OTHERS")" + +setup_environment() { + if [ -d /usr/local/go ]; then + export PATH="$PATH:/usr/local/go/bin" + fi + + if ! command -v go &> /dev/null; then + echo "Error: Go is not installed or not in PATH" + echo "Run install_go.sh to install Go" + exit 1 + fi +} + +get_build_tags() { + local extra_tags="$1" + if [ -n "$extra_tags" ]; then + echo "${DEFAULT_BUILD_TAGS},${extra_tags}" + else + echo "${DEFAULT_BUILD_TAGS}" + fi +} + +get_version() { + cd "$PROJECT_DIR" + GOHOSTOS=$(go env GOHOSTOS) + GOHOSTARCH=$(go env GOHOSTARCH) + CGO_ENABLED=0 GOOS=$GOHOSTOS GOARCH=$GOHOSTARCH go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest +} + +get_ldflags() { + local version + version=$(get_version) + local shared_ldflags + shared_ldflags=$(cat "$PROJECT_DIR/release/LDFLAGS") + echo "-X 'github.com/sagernet/sing-box/constant.Version=${version}' ${shared_ldflags} -s -w -buildid=" +} + +build_sing_box() { + local tags="$1" + local ldflags + ldflags=$(get_ldflags) + + echo "Building sing-box with tags: $tags" + cd "$PROJECT_DIR" + export GOTOOLCHAIN=local + go install -v -trimpath -ldflags "$ldflags" -tags "$tags" ./cmd/sing-box +} + +install_binary() { + local gopath + gopath=$(go env GOPATH) + echo "Installing binary to $INSTALL_BIN_PATH/$BINARY_NAME" + sudo cp "${gopath}/bin/${BINARY_NAME}" "${INSTALL_BIN_PATH}/" +} + +setup_config() { + echo "Setting up configuration" + sudo mkdir -p "$INSTALL_CONFIG_PATH" + if [ ! -f "$INSTALL_CONFIG_PATH/config.json" ]; then + sudo cp "$PROJECT_DIR/release/config/config.json" "$INSTALL_CONFIG_PATH/config.json" + echo "Default config installed to $INSTALL_CONFIG_PATH/config.json" + else + echo "Config already exists at $INSTALL_CONFIG_PATH/config.json (not overwriting)" + fi +} + +setup_systemd() { + echo "Setting up systemd service" + sudo cp "$SCRIPT_DIR/sing-box.service" "$SYSTEMD_SERVICE_PATH/" + sudo systemctl daemon-reload +} + +stop_service() { + if systemctl is-active --quiet sing-box; then + echo "Stopping sing-box service" + sudo systemctl stop sing-box + fi +} + +start_service() { + echo "Starting sing-box service" + sudo systemctl start sing-box +} + +restart_service() { + echo "Restarting sing-box service" + sudo systemctl restart sing-box +} diff --git a/release/local/debug.sh b/release/local/debug.sh new file mode 100644 index 00000000..d8651999 --- /dev/null +++ b/release/local/debug.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +setup_environment + +echo "Updating sing-box from git repository..." +cd "$PROJECT_DIR" +git fetch +git reset FETCH_HEAD --hard +git clean -fdx + +BUILD_TAGS=$(get_build_tags "debug") + +build_sing_box "$BUILD_TAGS" + +stop_service +install_binary +start_service + +echo "" +echo "Following service logs (Ctrl+C to exit)..." +sudo journalctl -u sing-box --output cat -f diff --git a/release/local/enable.sh b/release/local/enable.sh new file mode 100644 index 00000000..19929921 --- /dev/null +++ b/release/local/enable.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +sudo systemctl enable sing-box +sudo systemctl start sing-box +sudo journalctl -u sing-box --output cat -f diff --git a/release/local/install.sh b/release/local/install.sh new file mode 100644 index 00000000..d5bf94fc --- /dev/null +++ b/release/local/install.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +setup_environment + +BUILD_TAGS=$(get_build_tags) + +build_sing_box "$BUILD_TAGS" +install_binary +setup_config +setup_systemd + +echo "" +echo "Installation complete!" +echo "To enable and start the service, run: $SCRIPT_DIR/enable.sh" diff --git a/release/local/install_go.sh b/release/local/install_go.sh new file mode 100644 index 00000000..ea64fec4 --- /dev/null +++ b/release/local/install_go.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +go_version=$(curl -s https://raw.githubusercontent.com/actions/go-versions/main/versions-manifest.json | grep -oE '"version": "[0-9]{1}.[0-9]{1,}(.[0-9]{1,})?"' | head -1 | cut -d':' -f2 | sed 's/ //g; s/"//g') +curl -Lo go.tar.gz "https://go.dev/dl/go$go_version.linux-amd64.tar.gz" +sudo rm -rf /usr/local/go +sudo tar -C /usr/local -xzf go.tar.gz +rm go.tar.gz diff --git a/release/local/reinstall.sh b/release/local/reinstall.sh new file mode 100644 index 00000000..1daaa181 --- /dev/null +++ b/release/local/reinstall.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +setup_environment + +BUILD_TAGS=$(get_build_tags) + +build_sing_box "$BUILD_TAGS" + +stop_service +install_binary +start_service + +echo "" +echo "Reinstallation complete!" diff --git a/release/local/sing-box.service b/release/local/sing-box.service new file mode 100644 index 00000000..9a152ade --- /dev/null +++ b/release/local/sing-box.service @@ -0,0 +1,16 @@ +[Unit] +Description=sing-box service +Documentation=https://sing-box.sagernet.org +After=network.target nss-lookup.target network-online.target + +[Service] +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +ExecStart=/usr/local/bin/sing-box -D /var/lib/sing-box -C /usr/local/etc/sing-box run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/release/local/uninstall.sh b/release/local/uninstall.sh new file mode 100644 index 00000000..b9c89ab0 --- /dev/null +++ b/release/local/uninstall.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +echo "Uninstalling sing-box..." + +if systemctl is-active --quiet sing-box 2>/dev/null; then + echo "Stopping sing-box service..." + sudo systemctl stop sing-box +fi + +if systemctl is-enabled --quiet sing-box 2>/dev/null; then + echo "Disabling sing-box service..." + sudo systemctl disable sing-box +fi + +echo "Removing files..." +sudo rm -rf "$INSTALL_DATA_PATH" +sudo rm -rf "$INSTALL_BIN_PATH/$BINARY_NAME" +sudo rm -rf "$INSTALL_CONFIG_PATH" +sudo rm -rf "$SYSTEMD_SERVICE_PATH/sing-box.service" + +echo "Reloading systemd..." +sudo systemctl daemon-reload + +echo "" +echo "Uninstallation complete!" diff --git a/release/local/update.sh b/release/local/update.sh new file mode 100644 index 00000000..2331d270 --- /dev/null +++ b/release/local/update.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +echo "Updating sing-box from git repository..." +cd "$PROJECT_DIR" +git fetch +git reset FETCH_HEAD --hard +git clean -fdx + +echo "" +echo "Running reinstall..." +exec "$SCRIPT_DIR/reinstall.sh" \ No newline at end of file diff --git a/route/conn.go b/route/conn.go new file mode 100644 index 00000000..59afe539 --- /dev/null +++ b/route/conn.go @@ -0,0 +1,423 @@ +package route + +import ( + "context" + "io" + "net" + "net/netip" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tlsfragment" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/canceler" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" +) + +var _ adapter.ConnectionManager = (*ConnectionManager)(nil) + +type ConnectionManager struct { + logger logger.ContextLogger + access sync.Mutex + connections list.List[io.Closer] +} + +func NewConnectionManager(logger logger.ContextLogger) *ConnectionManager { + return &ConnectionManager{ + logger: logger, + } +} + +func (m *ConnectionManager) Start(stage adapter.StartStage) error { + return nil +} + +func (m *ConnectionManager) Count() int { + return m.connections.Len() +} + +func (m *ConnectionManager) CloseAll() { + m.access.Lock() + var closers []io.Closer + for element := m.connections.Front(); element != nil; { + nextElement := element.Next() + closers = append(closers, element.Value) + m.connections.Remove(element) + element = nextElement + } + m.access.Unlock() + for _, closer := range closers { + common.Close(closer) + } +} + +func (m *ConnectionManager) Close() error { + m.CloseAll() + return nil +} + +func (m *ConnectionManager) TrackConn(conn net.Conn) net.Conn { + m.access.Lock() + element := m.connections.PushBack(conn) + m.access.Unlock() + return &trackedConn{ + Conn: conn, + manager: m, + element: element, + } +} + +func (m *ConnectionManager) TrackPacketConn(conn net.PacketConn) net.PacketConn { + m.access.Lock() + element := m.connections.PushBack(conn) + m.access.Unlock() + return &trackedPacketConn{ + PacketConn: conn, + manager: m, + element: element, + } +} + +func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = adapter.WithContext(ctx, &metadata) + var ( + remoteConn net.Conn + err error + ) + if len(metadata.DestinationAddresses) > 0 || metadata.Destination.IsIP() { + remoteConn, err = dialer.DialSerialNetwork(ctx, this, N.NetworkTCP, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.NetworkType, metadata.FallbackNetworkType, metadata.FallbackDelay) + } else { + remoteConn, err = this.DialContext(ctx, N.NetworkTCP, metadata.Destination) + } + if err != nil { + var remoteString string + if len(metadata.DestinationAddresses) > 0 { + remoteString = "[" + strings.Join(common.Map(metadata.DestinationAddresses, netip.Addr.String), ",") + "]" + } else { + remoteString = metadata.Destination.String() + } + var dialerString string + if outbound, isOutbound := this.(adapter.Outbound); isOutbound { + dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + } + err = E.Cause(err, "open connection to ", remoteString, dialerString) + N.CloseOnHandshakeFailure(conn, onClose, err) + m.logger.ErrorContext(ctx, err) + return + } + err = N.ReportConnHandshakeSuccess(conn, remoteConn) + if err != nil { + err = E.Cause(err, "report handshake success") + remoteConn.Close() + N.CloseOnHandshakeFailure(conn, onClose, err) + m.logger.ErrorContext(ctx, err) + return + } + if metadata.TLSFragment || metadata.TLSRecordFragment { + remoteConn = tf.NewConn(remoteConn, ctx, metadata.TLSFragment, metadata.TLSRecordFragment, metadata.TLSFragmentFallbackDelay) + } + var done atomic.Bool + if m.kickWriteHandshake(ctx, conn, remoteConn, false, &done, onClose) { + return + } + if m.kickWriteHandshake(ctx, remoteConn, conn, true, &done, onClose) { + return + } + go m.connectionCopy(ctx, conn, remoteConn, false, &done, onClose) + go m.connectionCopy(ctx, remoteConn, conn, true, &done, onClose) +} + +func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dialer, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = adapter.WithContext(ctx, &metadata) + var ( + remotePacketConn net.PacketConn + remoteConn net.Conn + destinationAddress netip.Addr + err error + ) + if metadata.UDPConnect { + parallelDialer, isParallelDialer := this.(dialer.ParallelInterfaceDialer) + if len(metadata.DestinationAddresses) > 0 { + if isParallelDialer { + remoteConn, err = dialer.DialSerialNetwork(ctx, parallelDialer, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.NetworkType, metadata.FallbackNetworkType, metadata.FallbackDelay) + } else { + remoteConn, err = N.DialSerial(ctx, this, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses) + } + } else if metadata.Destination.IsIP() { + if isParallelDialer { + remoteConn, err = dialer.DialSerialNetwork(ctx, parallelDialer, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.NetworkType, metadata.FallbackNetworkType, metadata.FallbackDelay) + } else { + remoteConn, err = this.DialContext(ctx, N.NetworkUDP, metadata.Destination) + } + } else { + remoteConn, err = this.DialContext(ctx, N.NetworkUDP, metadata.Destination) + } + if err != nil { + var remoteString string + if len(metadata.DestinationAddresses) > 0 { + remoteString = "[" + strings.Join(common.Map(metadata.DestinationAddresses, netip.Addr.String), ",") + "]" + } else { + remoteString = metadata.Destination.String() + } + var dialerString string + if outbound, isOutbound := this.(adapter.Outbound); isOutbound { + dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + } + err = E.Cause(err, "open packet connection to ", remoteString, dialerString) + N.CloseOnHandshakeFailure(conn, onClose, err) + m.logger.ErrorContext(ctx, err) + return + } + remotePacketConn = bufio.NewUnbindPacketConn(remoteConn) + connRemoteAddr := M.AddrFromNet(remoteConn.RemoteAddr()) + if connRemoteAddr != metadata.Destination.Addr { + destinationAddress = connRemoteAddr + } + } else { + if len(metadata.DestinationAddresses) > 0 { + remotePacketConn, destinationAddress, err = dialer.ListenSerialNetworkPacket(ctx, this, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.NetworkType, metadata.FallbackNetworkType, metadata.FallbackDelay) + } else if packetDialer, withDestination := this.(dialer.PacketDialerWithDestination); withDestination { + remotePacketConn, destinationAddress, err = packetDialer.ListenPacketWithDestination(ctx, metadata.Destination) + } else { + remotePacketConn, err = this.ListenPacket(ctx, metadata.Destination) + } + if err != nil { + var dialerString string + if outbound, isOutbound := this.(adapter.Outbound); isOutbound { + dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + } + err = E.Cause(err, "listen packet connection using ", dialerString) + N.CloseOnHandshakeFailure(conn, onClose, err) + m.logger.ErrorContext(ctx, err) + return + } + } + err = N.ReportPacketConnHandshakeSuccess(conn, remotePacketConn) + if err != nil { + conn.Close() + remotePacketConn.Close() + m.logger.ErrorContext(ctx, "report handshake success: ", err) + return + } + if destinationAddress.IsValid() { + var originDestination M.Socksaddr + if metadata.RouteOriginalDestination.IsValid() { + originDestination = metadata.RouteOriginalDestination + } else { + originDestination = metadata.Destination + } + if natConn, loaded := common.Cast[bufio.NATPacketConn](conn); loaded { + natConn.UpdateDestination(destinationAddress) + } else { + destination := M.SocksaddrFrom(destinationAddress, metadata.Destination.Port) + if metadata.Destination != destination { + if metadata.UDPDisableDomainUnmapping { + remotePacketConn = bufio.NewUnidirectionalNATPacketConn(bufio.NewPacketConn(remotePacketConn), destination, originDestination) + } else { + remotePacketConn = bufio.NewNATPacketConn(bufio.NewPacketConn(remotePacketConn), destination, originDestination) + } + } else if metadata.RouteOriginalDestination.IsValid() && metadata.RouteOriginalDestination != metadata.Destination { + remotePacketConn = bufio.NewDestinationNATPacketConn(bufio.NewPacketConn(remotePacketConn), metadata.Destination, metadata.RouteOriginalDestination) + } + } + } else if metadata.RouteOriginalDestination.IsValid() && metadata.RouteOriginalDestination != metadata.Destination { + remotePacketConn = bufio.NewDestinationNATPacketConn(bufio.NewPacketConn(remotePacketConn), metadata.Destination, metadata.RouteOriginalDestination) + } + var udpTimeout time.Duration + if metadata.UDPTimeout > 0 { + udpTimeout = metadata.UDPTimeout + } else { + protocol := metadata.Protocol + if protocol == "" { + protocol = C.PortProtocols[metadata.Destination.Port] + } + if protocol != "" { + udpTimeout = C.ProtocolTimeouts[protocol] + } + } + if udpTimeout > 0 { + ctx, conn = canceler.NewPacketConn(ctx, conn, udpTimeout) + } + destination := bufio.NewPacketConn(remotePacketConn) + var done atomic.Bool + go m.packetConnectionCopy(ctx, conn, destination, false, &done, onClose) + go m.packetConnectionCopy(ctx, destination, conn, true, &done, onClose) +} + +func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn, destination net.Conn, direction bool, done *atomic.Bool, onClose N.CloseHandlerFunc) { + _, err := bufio.CopyWithIncreateBuffer(destination, source, bufio.DefaultIncreaseBufferAfter, bufio.DefaultBatchSize) + if err != nil { + common.Close(source, destination) + } else if duplexDst, isDuplex := destination.(N.WriteCloser); isDuplex { + err = duplexDst.CloseWrite() + if err != nil { + common.Close(source, destination) + } + } else { + destination.Close() + } + if done.Swap(true) { + if onClose != nil { + onClose(err) + } + common.Close(source, destination) + } + if !direction { + if err == nil { + m.logger.DebugContext(ctx, "connection upload finished") + } else if !E.IsClosedOrCanceled(err) { + m.logger.ErrorContext(ctx, "connection upload closed: ", err) + } else { + m.logger.TraceContext(ctx, "connection upload closed") + } + } else { + if err == nil { + m.logger.DebugContext(ctx, "connection download finished") + } else if !E.IsClosedOrCanceled(err) { + m.logger.ErrorContext(ctx, "connection download closed: ", err) + } else { + m.logger.TraceContext(ctx, "connection download closed") + } + } +} + +func (m *ConnectionManager) kickWriteHandshake(ctx context.Context, source net.Conn, destination net.Conn, direction bool, done *atomic.Bool, onClose N.CloseHandlerFunc) bool { + if !N.NeedHandshakeForWrite(destination) { + return false + } + var ( + cachedBuffer *buf.Buffer + wrotePayload bool + ) + sourceReader, readCounters := N.UnwrapCountReader(source, nil) + destinationWriter, writeCounters := N.UnwrapCountWriter(destination, nil) + if cachedReader, ok := sourceReader.(N.CachedReader); ok { + cachedBuffer = cachedReader.ReadCached() + } + var err error + if cachedBuffer != nil { + wrotePayload = true + dataLen := cachedBuffer.Len() + _, err = destinationWriter.Write(cachedBuffer.Bytes()) + cachedBuffer.Release() + if err == nil { + for _, counter := range readCounters { + counter(int64(dataLen)) + } + for _, counter := range writeCounters { + counter(int64(dataLen)) + } + } + } else { + _ = destination.SetWriteDeadline(time.Now().Add(C.ReadPayloadTimeout)) + _, err = destinationWriter.Write(nil) + _ = destination.SetWriteDeadline(time.Time{}) + } + if err == nil { + return false + } + if !wrotePayload && (E.IsMulti(err, os.ErrInvalid, context.DeadlineExceeded, io.EOF) || E.IsTimeout(err)) { + return false + } + if !done.Swap(true) { + if onClose != nil { + onClose(err) + } + } + common.Close(source, destination) + if !direction { + m.logger.ErrorContext(ctx, "connection upload handshake: ", err) + } else { + m.logger.ErrorContext(ctx, "connection download handshake: ", err) + } + return true +} + +func (m *ConnectionManager) packetConnectionCopy(ctx context.Context, source N.PacketReader, destination N.PacketWriter, direction bool, done *atomic.Bool, onClose N.CloseHandlerFunc) { + _, err := bufio.CopyPacket(destination, source) + if !direction { + if err == nil { + m.logger.DebugContext(ctx, "packet upload finished") + } else if E.IsClosedOrCanceled(err) { + m.logger.TraceContext(ctx, "packet upload closed") + } else { + m.logger.DebugContext(ctx, "packet upload closed: ", err) + } + } else { + if err == nil { + m.logger.DebugContext(ctx, "packet download finished") + } else if E.IsClosedOrCanceled(err) { + m.logger.TraceContext(ctx, "packet download closed") + } else { + m.logger.DebugContext(ctx, "packet download closed: ", err) + } + } + if !done.Swap(true) { + if onClose != nil { + onClose(err) + } + } + common.Close(source, destination) +} + +type trackedConn struct { + net.Conn + manager *ConnectionManager + element *list.Element[io.Closer] +} + +func (c *trackedConn) Close() error { + c.manager.access.Lock() + c.manager.connections.Remove(c.element) + c.manager.access.Unlock() + return c.Conn.Close() +} + +func (c *trackedConn) Upstream() any { + return c.Conn +} + +func (c *trackedConn) ReaderReplaceable() bool { + return true +} + +func (c *trackedConn) WriterReplaceable() bool { + return true +} + +type trackedPacketConn struct { + net.PacketConn + manager *ConnectionManager + element *list.Element[io.Closer] +} + +func (c *trackedPacketConn) Close() error { + c.manager.access.Lock() + c.manager.connections.Remove(c.element) + c.manager.access.Unlock() + return c.PacketConn.Close() +} + +func (c *trackedPacketConn) Upstream() any { + return bufio.NewPacketConn(c.PacketConn) +} + +func (c *trackedPacketConn) ReaderReplaceable() bool { + return true +} + +func (c *trackedPacketConn) WriterReplaceable() bool { + return true +} diff --git a/route/dns.go b/route/dns.go new file mode 100644 index 00000000..dee4a756 --- /dev/null +++ b/route/dns.go @@ -0,0 +1,109 @@ +package route + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + dnsOutbound "github.com/sagernet/sing-box/protocol/dns" + R "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/udpnat2" + + mDNS "github.com/miekg/dns" +) + +func (r *Router) hijackDNSStream(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + metadata.Destination = M.Socksaddr{} + for { + conn.SetReadDeadline(time.Now().Add(C.DNSTimeout)) + err := dnsOutbound.HandleStreamDNSRequest(ctx, r.dns, conn, metadata) + if err != nil { + if !E.IsClosedOrCanceled(err) { + return err + } else { + return nil + } + } + } +} + +func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetBuffers []*N.PacketBuffer, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + if natConn, isNatConn := conn.(udpnat.Conn); isNatConn { + metadata.Destination = M.Socksaddr{} + for _, packet := range packetBuffers { + buffer := packet.Buffer + destination := packet.Destination + N.PutPacketBuffer(packet) + go ExchangeDNSPacket(ctx, r.dns, r.logger, natConn, buffer, metadata, destination) + } + natConn.SetHandler(&dnsHijacker{ + router: r.dns, + logger: r.logger, + conn: conn, + ctx: ctx, + metadata: metadata, + onClose: onClose, + }) + return nil + } + err := dnsOutbound.NewDNSPacketConnection(ctx, r.dns, conn, packetBuffers, metadata) + N.CloseOnHandshakeFailure(conn, onClose, err) + if err != nil && !E.IsClosedOrCanceled(err) { + return E.Cause(err, "process DNS packet") + } + return nil +} + +func ExchangeDNSPacket(ctx context.Context, router adapter.DNSRouter, logger logger.ContextLogger, conn N.PacketConn, buffer *buf.Buffer, metadata adapter.InboundContext, destination M.Socksaddr) { + err := exchangeDNSPacket(ctx, router, conn, buffer, metadata, destination) + if err != nil && !R.IsRejected(err) && !E.IsClosedOrCanceled(err) { + logger.ErrorContext(ctx, E.Cause(err, "process DNS packet")) + } +} + +func exchangeDNSPacket(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, buffer *buf.Buffer, metadata adapter.InboundContext, destination M.Socksaddr) error { + var message mDNS.Msg + err := message.Unpack(buffer.Bytes()) + buffer.Release() + if err != nil { + return E.Cause(err, "unpack request") + } + response, err := router.Exchange(adapter.WithContext(ctx, &metadata), &message, adapter.DNSQueryOptions{}) + if err != nil { + return err + } + responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) + if err != nil { + return err + } + err = conn.WritePacket(responseBuffer, destination) + return err +} + +type dnsHijacker struct { + router adapter.DNSRouter + logger logger.ContextLogger + conn N.PacketConn + ctx context.Context + metadata adapter.InboundContext + onClose N.CloseHandlerFunc +} + +func (h *dnsHijacker) NewPacketEx(buffer *buf.Buffer, destination M.Socksaddr) { + go ExchangeDNSPacket(h.ctx, h.router, h.logger, h.conn, buffer, h.metadata, destination) +} + +func (h *dnsHijacker) Close() error { + if h.onClose != nil { + h.onClose(nil) + } + return nil +} diff --git a/route/neighbor_resolver_darwin.go b/route/neighbor_resolver_darwin.go new file mode 100644 index 00000000..a8884ae6 --- /dev/null +++ b/route/neighbor_resolver_darwin.go @@ -0,0 +1,239 @@ +//go:build darwin + +package route + +import ( + "net" + "net/netip" + "os" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +var defaultLeaseFiles = []string{ + "/var/db/dhcpd_leases", + "/tmp/dhcp.leases", +} + +type neighborResolver struct { + logger logger.ContextLogger + leaseFiles []string + access sync.RWMutex + neighborIPToMAC map[netip.Addr]net.HardwareAddr + leaseIPToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string + watcher *fswatch.Watcher + done chan struct{} +} + +func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) { + if len(leaseFiles) == 0 { + for _, path := range defaultLeaseFiles { + info, err := os.Stat(path) + if err == nil && info.Size() > 0 { + leaseFiles = append(leaseFiles, path) + } + } + } + return &neighborResolver{ + logger: resolverLogger, + leaseFiles: leaseFiles, + neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr), + leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + done: make(chan struct{}), + }, nil +} + +func (r *neighborResolver) Start() error { + err := r.loadNeighborTable() + if err != nil { + r.logger.Warn(E.Cause(err, "load neighbor table")) + } + r.doReloadLeaseFiles() + go r.subscribeNeighborUpdates() + if len(r.leaseFiles) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: r.leaseFiles, + Logger: r.logger, + Callback: func(_ string) { + r.doReloadLeaseFiles() + }, + }) + if err != nil { + r.logger.Warn(E.Cause(err, "create lease file watcher")) + } else { + r.watcher = watcher + err = watcher.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start lease file watcher")) + } + } + } + return nil +} + +func (r *neighborResolver) Close() error { + close(r.done) + if r.watcher != nil { + return r.watcher.Close() + } + return nil +} + +func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.neighborIPToMAC[address] + if found { + return mac, true + } + mac, found = r.leaseIPToMAC[address] + if found { + return mac, true + } + mac, found = extractMACFromEUI64(address) + if found { + return mac, true + } + return nil, false +} + +func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, macFound := r.neighborIPToMAC[address] + if !macFound { + mac, macFound = r.leaseIPToMAC[address] + } + if !macFound { + mac, macFound = extractMACFromEUI64(address) + } + if macFound { + hostname, found = r.macToHostname[mac.String()] + if found { + return hostname, true + } + } + return "", false +} + +func (r *neighborResolver) loadNeighborTable() error { + entries, err := ReadNeighborEntries() + if err != nil { + return err + } + r.access.Lock() + defer r.access.Unlock() + for _, entry := range entries { + r.neighborIPToMAC[entry.Address] = entry.MACAddress + } + return nil +} + +func (r *neighborResolver) subscribeNeighborUpdates() { + routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + r.logger.Warn(E.Cause(err, "subscribe neighbor updates")) + return + } + err = unix.SetNonblock(routeSocket, true) + if err != nil { + unix.Close(routeSocket) + r.logger.Warn(E.Cause(err, "set route socket nonblock")) + return + } + routeSocketFile := os.NewFile(uintptr(routeSocket), "route") + defer routeSocketFile.Close() + buffer := buf.NewPacket() + defer buffer.Release() + for { + select { + case <-r.done: + return + default: + } + err = setReadDeadline(routeSocketFile, 3*time.Second) + if err != nil { + r.logger.Warn(E.Cause(err, "set route socket read deadline")) + return + } + n, err := routeSocketFile.Read(buffer.FreeBytes()) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + } + r.logger.Warn(E.Cause(err, "receive neighbor update")) + continue + } + messages, err := route.ParseRIB(route.RIBTypeRoute, buffer.FreeBytes()[:n]) + if err != nil { + continue + } + for _, message := range messages { + routeMessage, isRouteMessage := message.(*route.RouteMessage) + if !isRouteMessage { + continue + } + if routeMessage.Flags&unix.RTF_LLINFO == 0 { + continue + } + address, mac, isDelete, ok := ParseRouteNeighborMessage(routeMessage) + if !ok { + continue + } + r.access.Lock() + if isDelete { + delete(r.neighborIPToMAC, address) + } else { + r.neighborIPToMAC[address] = mac + } + r.access.Unlock() + } + } +} + +func (r *neighborResolver) doReloadLeaseFiles() { + leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles) + r.access.Lock() + r.leaseIPToMAC = leaseIPToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() +} + +func setReadDeadline(file *os.File, timeout time.Duration) error { + rawConn, err := file.SyscallConn() + if err != nil { + return err + } + var controlErr error + err = rawConn.Control(func(fd uintptr) { + tv := unix.NsecToTimeval(int64(timeout)) + controlErr = unix.SetsockoptTimeval(int(fd), unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + }) + if err != nil { + return err + } + return controlErr +} diff --git a/route/neighbor_resolver_lease.go b/route/neighbor_resolver_lease.go new file mode 100644 index 00000000..e3f9c0b4 --- /dev/null +++ b/route/neighbor_resolver_lease.go @@ -0,0 +1,386 @@ +package route + +import ( + "bufio" + "encoding/hex" + "net" + "net/netip" + "os" + "strconv" + "strings" + "time" +) + +func parseLeaseFile(path string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + if strings.HasSuffix(path, "dhcpd_leases") { + parseBootpdLeases(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases4.csv") { + parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases6.csv") { + parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "dhcpd.leases") { + parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname) + return + } + parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname) +} + +func ReloadLeaseFiles(leaseFiles []string) (leaseIPToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + leaseIPToMAC = make(map[netip.Addr]net.HardwareAddr) + ipToHostname = make(map[netip.Addr]string) + macToHostname = make(map[string]string) + for _, path := range leaseFiles { + parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname) + } + return +} + +func parseDnsmasqOdhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "duid ") { + continue + } + if strings.HasPrefix(line, "# ") { + parseOdhcpdLine(line[2:], ipToMAC, ipToHostname, macToHostname) + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + expiry, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + if expiry != 0 && expiry < now { + continue + } + if strings.Contains(fields[1], ":") { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + ipToMAC[address] = mac + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } else { + var mac net.HardwareAddr + if len(fields) >= 5 { + duid, duidErr := parseDUID(fields[4]) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } + } +} + +func parseOdhcpdLine(line string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + fields := strings.Fields(line) + if len(fields) < 5 { + return + } + validTime, err := strconv.ParseInt(fields[4], 10, 64) + if err != nil { + return + } + if validTime == 0 { + return + } + if validTime > 0 && validTime < time.Now().Unix() { + return + } + hostname := fields[3] + if hostname == "-" || strings.HasPrefix(hostname, `broken\x20`) { + hostname = "" + } + if len(fields) >= 8 && fields[2] == "ipv4" { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + return + } + addressField := fields[7] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + return + } + address = address.Unmap() + ipToMAC[address] = mac + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + return + } + var mac net.HardwareAddr + duidHex := fields[1] + duidBytes, hexErr := hex.DecodeString(duidHex) + if hexErr == nil { + mac, _ = extractMACFromDUID(duidBytes) + } + for i := 7; i < len(fields); i++ { + addressField := fields[i] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func parseISCDhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentHostname string + var currentActive bool + var inLease bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") { + ipString := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {") + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(ipString)) + if addrOK { + currentIP = parsed.Unmap() + inLease = true + currentMAC = nil + currentHostname = "" + currentActive = false + } + continue + } + if line == "}" && inLease { + if currentActive && currentMAC != nil { + ipToMAC[currentIP] = currentMAC + if currentHostname != "" { + ipToHostname[currentIP] = currentHostname + macToHostname[currentMAC.String()] = currentHostname + } + } else { + delete(ipToMAC, currentIP) + delete(ipToHostname, currentIP) + } + inLease = false + continue + } + if !inLease { + continue + } + if strings.HasPrefix(line, "hardware ethernet ") { + macString := strings.TrimSuffix(strings.TrimPrefix(line, "hardware ethernet "), ";") + parsed, macErr := net.ParseMAC(macString) + if macErr == nil { + currentMAC = parsed + } + } else if strings.HasPrefix(line, "client-hostname ") { + hostname := strings.TrimSuffix(strings.TrimPrefix(line, "client-hostname "), ";") + hostname = strings.Trim(hostname, "\"") + if hostname != "" { + currentHostname = hostname + } + } else if strings.HasPrefix(line, "binding state ") { + state := strings.TrimSuffix(strings.TrimPrefix(line, "binding state "), ";") + currentActive = state == "active" + } + } +} + +func parseKeaCSV4(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 10 { + continue + } + if fields[9] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + ipToMAC[address] = mac + hostname := "" + if len(fields) > 8 { + hostname = fields[8] + } + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } +} + +func parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 14 { + continue + } + if fields[13] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + var mac net.HardwareAddr + if fields[12] != "" { + mac, _ = net.ParseMAC(fields[12]) + } + if mac == nil { + duid, duidErr := hex.DecodeString(strings.ReplaceAll(fields[1], ":", "")) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + hostname := "" + if len(fields) > 11 { + hostname = fields[11] + } + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func parseBootpdLeases(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + var currentName string + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentLease int64 + var inBlock bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "{" { + inBlock = true + currentName = "" + currentIP = netip.Addr{} + currentMAC = nil + currentLease = 0 + continue + } + if line == "}" && inBlock { + if currentMAC != nil && currentIP.IsValid() { + if currentLease == 0 || currentLease >= now { + ipToMAC[currentIP] = currentMAC + if currentName != "" { + ipToHostname[currentIP] = currentName + macToHostname[currentMAC.String()] = currentName + } + } + } + inBlock = false + continue + } + if !inBlock { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + switch key { + case "name": + currentName = value + case "ip_address": + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(value)) + if addrOK { + currentIP = parsed.Unmap() + } + case "hw_address": + typeAndMAC, hasSep := strings.CutPrefix(value, "1,") + if hasSep { + mac, macErr := net.ParseMAC(typeAndMAC) + if macErr == nil { + currentMAC = mac + } + } + case "lease": + leaseHex := strings.TrimPrefix(value, "0x") + parsed, parseErr := strconv.ParseInt(leaseHex, 16, 64) + if parseErr == nil { + currentLease = parsed + } + } + } +} diff --git a/route/neighbor_resolver_linux.go b/route/neighbor_resolver_linux.go new file mode 100644 index 00000000..b7991b4c --- /dev/null +++ b/route/neighbor_resolver_linux.go @@ -0,0 +1,224 @@ +//go:build linux + +package route + +import ( + "net" + "net/netip" + "os" + "slices" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "github.com/jsimonetti/rtnetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +var defaultLeaseFiles = []string{ + "/tmp/dhcp.leases", + "/var/lib/dhcp/dhcpd.leases", + "/var/lib/dhcpd/dhcpd.leases", + "/var/lib/kea/kea-leases4.csv", + "/var/lib/kea/kea-leases6.csv", +} + +type neighborResolver struct { + logger logger.ContextLogger + leaseFiles []string + access sync.RWMutex + neighborIPToMAC map[netip.Addr]net.HardwareAddr + leaseIPToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string + watcher *fswatch.Watcher + done chan struct{} +} + +func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) { + if len(leaseFiles) == 0 { + for _, path := range defaultLeaseFiles { + info, err := os.Stat(path) + if err == nil && info.Size() > 0 { + leaseFiles = append(leaseFiles, path) + } + } + } + return &neighborResolver{ + logger: resolverLogger, + leaseFiles: leaseFiles, + neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr), + leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + done: make(chan struct{}), + }, nil +} + +func (r *neighborResolver) Start() error { + err := r.loadNeighborTable() + if err != nil { + r.logger.Warn(E.Cause(err, "load neighbor table")) + } + r.doReloadLeaseFiles() + go r.subscribeNeighborUpdates() + if len(r.leaseFiles) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: r.leaseFiles, + Logger: r.logger, + Callback: func(_ string) { + r.doReloadLeaseFiles() + }, + }) + if err != nil { + r.logger.Warn(E.Cause(err, "create lease file watcher")) + } else { + r.watcher = watcher + err = watcher.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start lease file watcher")) + } + } + } + return nil +} + +func (r *neighborResolver) Close() error { + close(r.done) + if r.watcher != nil { + return r.watcher.Close() + } + return nil +} + +func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.neighborIPToMAC[address] + if found { + return mac, true + } + mac, found = r.leaseIPToMAC[address] + if found { + return mac, true + } + mac, found = extractMACFromEUI64(address) + if found { + return mac, true + } + return nil, false +} + +func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, macFound := r.neighborIPToMAC[address] + if !macFound { + mac, macFound = r.leaseIPToMAC[address] + } + if !macFound { + mac, macFound = extractMACFromEUI64(address) + } + if macFound { + hostname, found = r.macToHostname[mac.String()] + if found { + return hostname, true + } + } + return "", false +} + +func (r *neighborResolver) loadNeighborTable() error { + connection, err := rtnetlink.Dial(nil) + if err != nil { + return E.Cause(err, "dial rtnetlink") + } + defer connection.Close() + neighbors, err := connection.Neigh.List() + if err != nil { + return E.Cause(err, "list neighbors") + } + r.access.Lock() + defer r.access.Unlock() + for _, neigh := range neighbors { + if neigh.Attributes == nil { + continue + } + if neigh.Attributes.LLAddress == nil || len(neigh.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neigh.Attributes.Address) + if !ok { + continue + } + r.neighborIPToMAC[address] = slices.Clone(neigh.Attributes.LLAddress) + } + return nil +} + +func (r *neighborResolver) subscribeNeighborUpdates() { + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + r.logger.Warn(E.Cause(err, "subscribe neighbor updates")) + return + } + defer connection.Close() + for { + select { + case <-r.done: + return + default: + } + err = connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + r.logger.Warn(E.Cause(err, "set netlink read deadline")) + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + } + r.logger.Warn(E.Cause(err, "receive neighbor update")) + continue + } + for _, message := range messages { + address, mac, isDelete, ok := ParseNeighborMessage(message) + if !ok { + continue + } + r.access.Lock() + if isDelete { + delete(r.neighborIPToMAC, address) + } else { + r.neighborIPToMAC[address] = mac + } + r.access.Unlock() + } + } +} + +func (r *neighborResolver) doReloadLeaseFiles() { + leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles) + r.access.Lock() + r.leaseIPToMAC = leaseIPToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() +} diff --git a/route/neighbor_resolver_parse.go b/route/neighbor_resolver_parse.go new file mode 100644 index 00000000..1979b7ea --- /dev/null +++ b/route/neighbor_resolver_parse.go @@ -0,0 +1,50 @@ +package route + +import ( + "encoding/binary" + "encoding/hex" + "net" + "net/netip" + "slices" + "strings" +) + +func extractMACFromDUID(duid []byte) (net.HardwareAddr, bool) { + if len(duid) < 4 { + return nil, false + } + duidType := binary.BigEndian.Uint16(duid[0:2]) + hwType := binary.BigEndian.Uint16(duid[2:4]) + if hwType != 1 { + return nil, false + } + switch duidType { + case 1: + if len(duid) < 14 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[8:14])), true + case 3: + if len(duid) < 10 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[4:10])), true + } + return nil, false +} + +func extractMACFromEUI64(address netip.Addr) (net.HardwareAddr, bool) { + if !address.Is6() { + return nil, false + } + b := address.As16() + if b[11] != 0xff || b[12] != 0xfe { + return nil, false + } + return net.HardwareAddr{b[8] ^ 0x02, b[9], b[10], b[13], b[14], b[15]}, true +} + +func parseDUID(s string) ([]byte, error) { + cleaned := strings.ReplaceAll(s, ":", "") + return hex.DecodeString(cleaned) +} diff --git a/route/neighbor_resolver_platform.go b/route/neighbor_resolver_platform.go new file mode 100644 index 00000000..ddb9a995 --- /dev/null +++ b/route/neighbor_resolver_platform.go @@ -0,0 +1,84 @@ +package route + +import ( + "net" + "net/netip" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +type platformNeighborResolver struct { + logger logger.ContextLogger + platform adapter.PlatformInterface + access sync.RWMutex + ipToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string +} + +func newPlatformNeighborResolver(resolverLogger logger.ContextLogger, platform adapter.PlatformInterface) adapter.NeighborResolver { + return &platformNeighborResolver{ + logger: resolverLogger, + platform: platform, + ipToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + } +} + +func (r *platformNeighborResolver) Start() error { + return r.platform.StartNeighborMonitor(r) +} + +func (r *platformNeighborResolver) Close() error { + return r.platform.CloseNeighborMonitor(r) +} + +func (r *platformNeighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.ipToMAC[address] + if found { + return mac, true + } + return extractMACFromEUI64(address) +} + +func (r *platformNeighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, found := r.ipToMAC[address] + if !found { + mac, found = extractMACFromEUI64(address) + } + if !found { + return "", false + } + hostname, found = r.macToHostname[mac.String()] + return hostname, found +} + +func (r *platformNeighborResolver) UpdateNeighborTable(entries []adapter.NeighborEntry) { + ipToMAC := make(map[netip.Addr]net.HardwareAddr) + ipToHostname := make(map[netip.Addr]string) + macToHostname := make(map[string]string) + for _, entry := range entries { + ipToMAC[entry.Address] = entry.MACAddress + if entry.Hostname != "" { + ipToHostname[entry.Address] = entry.Hostname + macToHostname[entry.MACAddress.String()] = entry.Hostname + } + } + r.access.Lock() + r.ipToMAC = ipToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() + r.logger.Info("updated neighbor table: ", len(entries), " entries") +} diff --git a/route/neighbor_resolver_stub.go b/route/neighbor_resolver_stub.go new file mode 100644 index 00000000..177a1fcc --- /dev/null +++ b/route/neighbor_resolver_stub.go @@ -0,0 +1,14 @@ +//go:build !linux && !darwin + +package route + +import ( + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +func newNeighborResolver(_ logger.ContextLogger, _ []string) (adapter.NeighborResolver, error) { + return nil, os.ErrInvalid +} diff --git a/route/neighbor_table_darwin.go b/route/neighbor_table_darwin.go new file mode 100644 index 00000000..8ca2d0f0 --- /dev/null +++ b/route/neighbor_table_darwin.go @@ -0,0 +1,104 @@ +//go:build darwin + +package route + +import ( + "net" + "net/netip" + "syscall" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func ReadNeighborEntries() ([]adapter.NeighborEntry, error) { + var entries []adapter.NeighborEntry + ipv4Entries, err := readNeighborEntriesAF(syscall.AF_INET) + if err != nil { + return nil, E.Cause(err, "read IPv4 neighbors") + } + entries = append(entries, ipv4Entries...) + ipv6Entries, err := readNeighborEntriesAF(syscall.AF_INET6) + if err != nil { + return nil, E.Cause(err, "read IPv6 neighbors") + } + entries = append(entries, ipv6Entries...) + return entries, nil +} + +func readNeighborEntriesAF(addressFamily int) ([]adapter.NeighborEntry, error) { + rib, err := route.FetchRIB(addressFamily, route.RIBType(syscall.NET_RT_FLAGS), syscall.RTF_LLINFO) + if err != nil { + return nil, err + } + messages, err := route.ParseRIB(route.RIBType(syscall.NET_RT_FLAGS), rib) + if err != nil { + return nil, err + } + var entries []adapter.NeighborEntry + for _, message := range messages { + routeMessage, isRouteMessage := message.(*route.RouteMessage) + if !isRouteMessage { + continue + } + address, macAddress, ok := parseRouteNeighborEntry(routeMessage) + if !ok { + continue + } + entries = append(entries, adapter.NeighborEntry{ + Address: address, + MACAddress: macAddress, + }) + } + return entries, nil +} + +func parseRouteNeighborEntry(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, ok bool) { + if len(message.Addrs) <= unix.RTAX_GATEWAY { + return + } + gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr) + if !isLinkAddr || len(gateway.Addr) < 6 { + return + } + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + address = netip.AddrFrom4(destination.IP) + case *route.Inet6Addr: + address = netip.AddrFrom16(destination.IP) + default: + return + } + macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr))) + copy(macAddress, gateway.Addr) + ok = true + return +} + +func ParseRouteNeighborMessage(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) { + isDelete = message.Type == unix.RTM_DELETE + if len(message.Addrs) <= unix.RTAX_GATEWAY { + return + } + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + address = netip.AddrFrom4(destination.IP) + case *route.Inet6Addr: + address = netip.AddrFrom16(destination.IP) + default: + return + } + if !isDelete { + gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr) + if !isLinkAddr || len(gateway.Addr) < 6 { + return + } + macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr))) + copy(macAddress, gateway.Addr) + } + ok = true + return +} diff --git a/route/neighbor_table_linux.go b/route/neighbor_table_linux.go new file mode 100644 index 00000000..61a214fd --- /dev/null +++ b/route/neighbor_table_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package route + +import ( + "net" + "net/netip" + "slices" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/jsimonetti/rtnetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func ReadNeighborEntries() ([]adapter.NeighborEntry, error) { + connection, err := rtnetlink.Dial(nil) + if err != nil { + return nil, E.Cause(err, "dial rtnetlink") + } + defer connection.Close() + neighbors, err := connection.Neigh.List() + if err != nil { + return nil, E.Cause(err, "list neighbors") + } + var entries []adapter.NeighborEntry + for _, neighbor := range neighbors { + if neighbor.Attributes == nil { + continue + } + if neighbor.Attributes.LLAddress == nil || len(neighbor.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neighbor.Attributes.Address) + if !ok { + continue + } + entries = append(entries, adapter.NeighborEntry{ + Address: address, + MACAddress: slices.Clone(neighbor.Attributes.LLAddress), + }) + } + return entries, nil +} + +func ParseNeighborMessage(message netlink.Message) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) { + var neighMessage rtnetlink.NeighMessage + err := neighMessage.UnmarshalBinary(message.Data) + if err != nil { + return + } + if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 { + return + } + address, ok = netip.AddrFromSlice(neighMessage.Attributes.Address) + if !ok { + return + } + isDelete = message.Header.Type == unix.RTM_DELNEIGH + if !isDelete && neighMessage.Attributes.LLAddress == nil { + ok = false + return + } + macAddress = slices.Clone(neighMessage.Attributes.LLAddress) + return +} diff --git a/route/network.go b/route/network.go new file mode 100644 index 00000000..1393ca90 --- /dev/null +++ b/route/network.go @@ -0,0 +1,542 @@ +package route + +import ( + "context" + "errors" + "net" + "net/netip" + "os" + "runtime" + "strings" + "sync" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/settings" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/winpowrprof" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" + + "golang.org/x/exp/slices" +) + +var _ adapter.NetworkManager = (*NetworkManager)(nil) + +type NetworkManager struct { + logger logger.ContextLogger + interfaceFinder *control.DefaultInterfaceFinder + networkInterfaces common.TypedValue[[]adapter.NetworkInterface] + + autoDetectInterface bool + defaultOptions adapter.NetworkOptions + autoRedirectOutputMark uint32 + networkMonitor tun.NetworkUpdateMonitor + interfaceMonitor tun.DefaultInterfaceMonitor + packageManager tun.PackageManager + powerListener winpowrprof.EventListener + pauseManager pause.Manager + platformInterface adapter.PlatformInterface + connectionManager adapter.ConnectionManager + endpoint adapter.EndpointManager + inbound adapter.InboundManager + outbound adapter.OutboundManager + needWIFIState bool + wifiMonitor settings.WIFIMonitor + wifiState adapter.WIFIState + wifiStateMutex sync.RWMutex + started bool +} + +func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options option.RouteOptions, dnsOptions option.DNSOptions) (*NetworkManager, error) { + defaultDomainResolver := common.PtrValueOrDefault(options.DefaultDomainResolver) + if options.AutoDetectInterface && !(C.IsLinux || C.IsDarwin || C.IsWindows) { + return nil, E.New("`auto_detect_interface` is only supported on Linux, Windows and macOS") + } else if options.OverrideAndroidVPN && !C.IsAndroid { + return nil, E.New("`override_android_vpn` is only supported on Android") + } else if options.DefaultInterface != "" && !(C.IsLinux || C.IsDarwin || C.IsWindows) { + return nil, E.New("`default_interface` is only supported on Linux, Windows and macOS") + } else if options.DefaultMark != 0 && !C.IsLinux { + return nil, E.New("`default_mark` is only supported on linux") + } + nm := &NetworkManager{ + logger: logger, + interfaceFinder: control.NewDefaultInterfaceFinder(), + autoDetectInterface: options.AutoDetectInterface, + defaultOptions: adapter.NetworkOptions{ + BindInterface: options.DefaultInterface, + RoutingMark: uint32(options.DefaultMark), + DomainResolver: defaultDomainResolver.Server, + DomainResolveOptions: adapter.DNSQueryOptions{ + Strategy: C.DomainStrategy(defaultDomainResolver.Strategy), + DisableCache: defaultDomainResolver.DisableCache, + DisableOptimisticCache: defaultDomainResolver.DisableOptimisticCache, + RewriteTTL: defaultDomainResolver.RewriteTTL, + ClientSubnet: defaultDomainResolver.ClientSubnet.Build(netip.Prefix{}), + }, + NetworkStrategy: (*C.NetworkStrategy)(options.DefaultNetworkStrategy), + NetworkType: common.Map(options.DefaultNetworkType, option.InterfaceType.Build), + FallbackNetworkType: common.Map(options.DefaultFallbackNetworkType, option.InterfaceType.Build), + FallbackDelay: time.Duration(options.DefaultFallbackDelay), + }, + pauseManager: service.FromContext[pause.Manager](ctx), + platformInterface: service.FromContext[adapter.PlatformInterface](ctx), + connectionManager: service.FromContext[adapter.ConnectionManager](ctx), + endpoint: service.FromContext[adapter.EndpointManager](ctx), + inbound: service.FromContext[adapter.InboundManager](ctx), + outbound: service.FromContext[adapter.OutboundManager](ctx), + needWIFIState: hasRule(options.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule), + } + if options.DefaultNetworkStrategy != nil { + if options.DefaultInterface != "" { + return nil, E.New("`default_network_strategy` is conflict with `default_interface`") + } + if !options.AutoDetectInterface { + return nil, E.New("`auto_detect_interface` is required by `default_network_strategy`") + } + } + usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil + enforceInterfaceMonitor := options.AutoDetectInterface + if !usePlatformDefaultInterfaceMonitor { + networkMonitor, err := tun.NewNetworkUpdateMonitor(logger) + if !((err != nil && !enforceInterfaceMonitor) || errors.Is(err, os.ErrInvalid)) { + if err != nil { + return nil, E.Cause(err, "create network monitor") + } + nm.networkMonitor = networkMonitor + interfaceMonitor, err := tun.NewDefaultInterfaceMonitor(nm.networkMonitor, logger, tun.DefaultInterfaceMonitorOptions{ + InterfaceFinder: nm.interfaceFinder, + OverrideAndroidVPN: options.OverrideAndroidVPN, + UnderNetworkExtension: nm.platformInterface != nil && nm.platformInterface.UnderNetworkExtension(), + }) + if err != nil { + return nil, E.New("auto_detect_interface unsupported on current platform") + } + interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate) + nm.interfaceMonitor = interfaceMonitor + } + } else { + interfaceMonitor := nm.platformInterface.CreateDefaultInterfaceMonitor(logger) + interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate) + nm.interfaceMonitor = interfaceMonitor + } + return nm, nil +} + +func (r *NetworkManager) Start(stage adapter.StartStage) error { + monitor := taskmonitor.New(r.logger, C.StartTimeout) + switch stage { + case adapter.StartStateInitialize: + if r.networkMonitor != nil { + monitor.Start("initialize network monitor") + err := r.networkMonitor.Start() + monitor.Finish() + if err != nil { + return err + } + } + if r.interfaceMonitor != nil { + monitor.Start("initialize interface monitor") + err := r.interfaceMonitor.Start() + monitor.Finish() + if err != nil { + return err + } + } + case adapter.StartStateStart: + if runtime.GOOS == "windows" { + powerListener, err := winpowrprof.NewEventListener(r.notifyWindowsPowerEvent) + if err == nil { + r.powerListener = powerListener + } else { + r.logger.Warn("initialize power listener: ", err) + } + } + if r.powerListener != nil { + monitor.Start("start power listener") + err := r.powerListener.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "start power listener") + } + } + if C.IsAndroid && r.platformInterface == nil { + monitor.Start("initialize package manager") + packageManager, err := tun.NewPackageManager(tun.PackageManagerOptions{ + Callback: r, + Logger: r.logger, + }) + monitor.Finish() + if err != nil { + return E.Cause(err, "create package manager") + } + monitor.Start("start package manager") + err = packageManager.Start() + monitor.Finish() + if err != nil { + r.logger.Warn("initialize package manager: ", err) + } else { + r.packageManager = packageManager + } + } + case adapter.StartStatePostStart: + if r.needWIFIState && !(r.platformInterface != nil && r.platformInterface.UsePlatformWIFIMonitor()) { + wifiMonitor, err := settings.NewWIFIMonitor(r.onWIFIStateChanged) + if err != nil { + if err != os.ErrInvalid { + r.logger.Warn(E.Cause(err, "create WIFI monitor")) + } + } else { + r.wifiMonitor = wifiMonitor + err = r.wifiMonitor.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start WIFI monitor")) + } + } + } + r.started = true + } + return nil +} + +func (r *NetworkManager) Initialize(ruleSets []adapter.RuleSet) { + for _, ruleSet := range ruleSets { + metadata := ruleSet.Metadata() + if metadata.ContainsWIFIRule { + r.needWIFIState = true + break + } + } +} + +func (r *NetworkManager) Close() error { + monitor := taskmonitor.New(r.logger, C.StopTimeout) + var err error + if r.packageManager != nil { + monitor.Start("close package manager") + err = E.Append(err, r.packageManager.Close(), func(err error) error { + return E.Cause(err, "close package manager") + }) + monitor.Finish() + } + if r.powerListener != nil { + monitor.Start("close power listener") + err = E.Append(err, r.powerListener.Close(), func(err error) error { + return E.Cause(err, "close power listener") + }) + monitor.Finish() + } + if r.interfaceMonitor != nil { + monitor.Start("close interface monitor") + err = E.Append(err, r.interfaceMonitor.Close(), func(err error) error { + return E.Cause(err, "close interface monitor") + }) + monitor.Finish() + } + if r.networkMonitor != nil { + monitor.Start("close network monitor") + err = E.Append(err, r.networkMonitor.Close(), func(err error) error { + return E.Cause(err, "close network monitor") + }) + monitor.Finish() + } + if r.wifiMonitor != nil { + monitor.Start("close WIFI monitor") + err = E.Append(err, r.wifiMonitor.Close(), func(err error) error { + return E.Cause(err, "close WIFI monitor") + }) + monitor.Finish() + } + return err +} + +func (r *NetworkManager) InterfaceFinder() control.InterfaceFinder { + return r.interfaceFinder +} + +func (r *NetworkManager) UpdateInterfaces() error { + if r.platformInterface == nil || !r.platformInterface.UsePlatformNetworkInterfaces() { + return r.interfaceFinder.Update() + } else { + interfaces, err := r.platformInterface.NetworkInterfaces() + if err != nil { + return err + } + if C.IsDarwin { + err = r.interfaceFinder.Update() + if err != nil { + return err + } + // NEInterface only provides name,index and type + interfaces = common.Map(interfaces, func(it adapter.NetworkInterface) adapter.NetworkInterface { + iif, _ := r.interfaceFinder.ByIndex(it.Index) + if iif != nil { + it.Interface = *iif + } + return it + }) + } else { + r.interfaceFinder.UpdateInterfaces(common.Map(interfaces, func(it adapter.NetworkInterface) control.Interface { return it.Interface })) + } + oldInterfaces := r.networkInterfaces.Load() + newInterfaces := common.Filter(interfaces, func(it adapter.NetworkInterface) bool { + return it.Flags&net.FlagUp != 0 + }) + r.networkInterfaces.Store(newInterfaces) + if len(newInterfaces) > 0 && !slices.EqualFunc(oldInterfaces, newInterfaces, func(oldInterface adapter.NetworkInterface, newInterface adapter.NetworkInterface) bool { + return oldInterface.Interface.Index == newInterface.Interface.Index && + oldInterface.Interface.Name == newInterface.Interface.Name && + oldInterface.Interface.Flags == newInterface.Interface.Flags && + oldInterface.Type == newInterface.Type && + oldInterface.Expensive == newInterface.Expensive && + oldInterface.Constrained == newInterface.Constrained + }) { + r.logger.Info("updated available networks: ", strings.Join(common.Map(newInterfaces, func(it adapter.NetworkInterface) string { + var options []string + options = append(options, F.ToString(it.Type)) + if it.Expensive { + options = append(options, "expensive") + } + if it.Constrained { + options = append(options, "constrained") + } + return F.ToString(it.Name, " (", strings.Join(options, ", "), ")") + }), ", ")) + } + return nil + } +} + +func (r *NetworkManager) DefaultNetworkInterface() *adapter.NetworkInterface { + iif := r.interfaceMonitor.DefaultInterface() + if iif == nil { + return nil + } + for _, it := range r.networkInterfaces.Load() { + if it.Interface.Index == iif.Index { + return &it + } + } + return &adapter.NetworkInterface{Interface: *iif} +} + +func (r *NetworkManager) NetworkInterfaces() []adapter.NetworkInterface { + return r.networkInterfaces.Load() +} + +func (r *NetworkManager) AutoDetectInterface() bool { + return r.autoDetectInterface +} + +func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func { + if r.platformInterface != nil && r.platformInterface.UsePlatformAutoDetectInterfaceControl() { + return func(network, address string, conn syscall.RawConn) error { + return control.Raw(conn, func(fd uintptr) error { + return r.platformInterface.AutoDetectInterfaceControl(int(fd)) + }) + } + } else { + if r.interfaceMonitor == nil { + return nil + } + return control.BindToInterfaceFunc(r.interfaceFinder, func(network string, address string) (interfaceName string, interfaceIndex int, err error) { + remoteAddr := M.ParseSocksaddr(address).Addr + if remoteAddr.IsValid() { + iif, err := r.interfaceFinder.ByAddr(remoteAddr) + if err == nil { + return iif.Name, iif.Index, nil + } + } + defaultInterface := r.interfaceMonitor.DefaultInterface() + if defaultInterface == nil { + return "", -1, tun.ErrNoRoute + } + return defaultInterface.Name, defaultInterface.Index, nil + }) + } +} + +func (r *NetworkManager) ProtectFunc() control.Func { + if r.platformInterface != nil && r.platformInterface.UsePlatformAutoDetectInterfaceControl() { + return func(network, address string, conn syscall.RawConn) error { + return control.Raw(conn, func(fd uintptr) error { + return r.platformInterface.AutoDetectInterfaceControl(int(fd)) + }) + } + } + return nil +} + +func (r *NetworkManager) DefaultOptions() adapter.NetworkOptions { + return r.defaultOptions +} + +func (r *NetworkManager) RegisterAutoRedirectOutputMark(mark uint32) error { + if r.autoRedirectOutputMark > 0 { + return E.New("only one auto-redirect can be configured") + } + r.autoRedirectOutputMark = mark + return nil +} + +func (r *NetworkManager) AutoRedirectOutputMark() uint32 { + return r.autoRedirectOutputMark +} + +func (r *NetworkManager) AutoRedirectOutputMarkFunc() control.Func { + return func(network, address string, conn syscall.RawConn) error { + if r.autoRedirectOutputMark == 0 { + return nil + } + return control.RoutingMark(r.autoRedirectOutputMark)(network, address, conn) + } +} + +func (r *NetworkManager) NetworkMonitor() tun.NetworkUpdateMonitor { + return r.networkMonitor +} + +func (r *NetworkManager) InterfaceMonitor() tun.DefaultInterfaceMonitor { + return r.interfaceMonitor +} + +func (r *NetworkManager) PackageManager() tun.PackageManager { + return r.packageManager +} + +func (r *NetworkManager) NeedWIFIState() bool { + return r.needWIFIState +} + +func (r *NetworkManager) WIFIState() adapter.WIFIState { + r.wifiStateMutex.RLock() + defer r.wifiStateMutex.RUnlock() + return r.wifiState +} + +func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) { + r.wifiStateMutex.Lock() + if state != r.wifiState { + r.wifiState = state + r.wifiStateMutex.Unlock() + if state.SSID != "" { + r.logger.Info("WIFI state changed: SSID=", state.SSID, ", BSSID=", state.BSSID) + } else { + r.logger.Info("WIFI disconnected") + } + } else { + r.wifiStateMutex.Unlock() + } +} + +func (r *NetworkManager) UpdateWIFIState() { + var state adapter.WIFIState + if r.wifiMonitor != nil { + state = r.wifiMonitor.ReadWIFIState() + } else if r.platformInterface != nil && r.platformInterface.UsePlatformWIFIMonitor() { + state = r.platformInterface.ReadWIFIState() + } else { + return + } + r.onWIFIStateChanged(state) +} + +func (r *NetworkManager) ResetNetwork() { + if r.connectionManager != nil { + r.connectionManager.CloseAll() + } + + for _, endpoint := range r.endpoint.Endpoints() { + listener, isListener := endpoint.(adapter.InterfaceUpdateListener) + if isListener { + listener.InterfaceUpdated() + } + } + + for _, inbound := range r.inbound.Inbounds() { + listener, isListener := inbound.(adapter.InterfaceUpdateListener) + if isListener { + listener.InterfaceUpdated() + } + } + + for _, outbound := range r.outbound.Outbounds() { + listener, isListener := outbound.(adapter.InterfaceUpdateListener) + if isListener { + listener.InterfaceUpdated() + } + } +} + +func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interface, flags int) { + if defaultInterface == nil { + r.pauseManager.NetworkPause() + r.logger.Error("missing default interface") + return + } + + r.pauseManager.NetworkWake() + var options []string + options = append(options, F.ToString("index ", defaultInterface.Index)) + if C.IsAndroid && r.platformInterface == nil { + var vpnStatus string + if r.interfaceMonitor.AndroidVPNEnabled() { + vpnStatus = "enabled" + } else { + vpnStatus = "disabled" + } + options = append(options, "vpn "+vpnStatus) + } else if r.platformInterface != nil { + networkInterface := common.Find(r.networkInterfaces.Load(), func(it adapter.NetworkInterface) bool { + return it.Interface.Index == defaultInterface.Index + }) + if networkInterface.Name == "" { + // race + return + } + options = append(options, F.ToString("type ", networkInterface.Type)) + if networkInterface.Expensive { + options = append(options, "expensive") + } + if networkInterface.Constrained { + options = append(options, "constrained") + } + } + r.logger.Info("updated default interface ", defaultInterface.Name, ", ", strings.Join(options, ", ")) + r.UpdateWIFIState() + + if !r.started { + return + } + r.ResetNetwork() +} + +func (r *NetworkManager) notifyWindowsPowerEvent(event int) { + switch event { + case winpowrprof.EVENT_SUSPEND: + r.pauseManager.DevicePause() + r.ResetNetwork() + case winpowrprof.EVENT_RESUME: + if !r.pauseManager.IsDevicePaused() { + return + } + fallthrough + case winpowrprof.EVENT_RESUME_AUTOMATIC: + r.pauseManager.DeviceWake() + r.ResetNetwork() + } +} + +func (r *NetworkManager) OnPackagesUpdated(packages int, sharedUsers int) { + r.logger.Info("updated packages list: ", packages, " packages, ", sharedUsers, " shared users") +} diff --git a/route/platform_searcher.go b/route/platform_searcher.go new file mode 100644 index 00000000..20fbda3f --- /dev/null +++ b/route/platform_searcher.go @@ -0,0 +1,49 @@ +package route + +import ( + "context" + "net/netip" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/process" + N "github.com/sagernet/sing/common/network" +) + +type platformSearcher struct { + platform adapter.PlatformInterface +} + +func newPlatformSearcher(platform adapter.PlatformInterface) process.Searcher { + return &platformSearcher{platform: platform} +} + +func (s *platformSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + if !s.platform.UsePlatformConnectionOwnerFinder() { + return nil, process.ErrNotFound + } + + var ipProtocol int32 + switch N.NetworkName(network) { + case N.NetworkTCP: + ipProtocol = syscall.IPPROTO_TCP + case N.NetworkUDP: + ipProtocol = syscall.IPPROTO_UDP + default: + return nil, process.ErrNotFound + } + + request := &adapter.FindConnectionOwnerRequest{ + IpProtocol: ipProtocol, + SourceAddress: source.Addr().String(), + SourcePort: int32(source.Port()), + DestinationAddress: destination.Addr().String(), + DestinationPort: int32(destination.Port()), + } + + return s.platform.FindConnectionOwner(request) +} + +func (s *platformSearcher) Close() error { + return nil +} diff --git a/route/process_cache.go b/route/process_cache.go new file mode 100644 index 00000000..691a4e8e --- /dev/null +++ b/route/process_cache.go @@ -0,0 +1,34 @@ +package route + +import ( + "context" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/process" +) + +type processCacheKey struct { + Network string + Source netip.AddrPort + Destination netip.AddrPort +} + +type processCacheEntry struct { + result *adapter.ConnectionOwner + err error +} + +func (r *Router) findProcessInfoCached(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + key := processCacheKey{ + Network: network, + Source: source, + Destination: destination, + } + if entry, ok := r.processCache.Get(key); ok { + return entry.result, entry.err + } + result, err := process.FindProcessInfo(r.processSearcher, ctx, network, source, destination) + r.processCache.Add(key, processCacheEntry{result: result, err: err}) + return result, err +} diff --git a/route/route.go b/route/route.go new file mode 100644 index 00000000..32e07bae --- /dev/null +++ b/route/route.go @@ -0,0 +1,833 @@ +package route + +import ( + "context" + "errors" + "net" + "net/netip" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + R "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-mux" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/ping" + "github.com/sagernet/sing-vmess" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/bufio/deadline" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" + + "golang.org/x/exp/slices" +) + +// Deprecated: use RouteConnectionEx instead. +func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + done := make(chan interface{}) + err := r.routeConnection(ctx, conn, metadata, N.OnceClose(func(it error) { + close(done) + })) + if err != nil { + return err + } + select { + case <-done: + case <-r.ctx.Done(): + } + return nil +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := r.routeConnection(ctx, conn, metadata, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + if E.IsClosedOrCanceled(err) || R.IsRejected(err) { + r.logger.DebugContext(ctx, "connection closed: ", err) + } else { + r.logger.ErrorContext(ctx, err) + } + } +} + +func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + //nolint:staticcheck + if metadata.InboundDetour != "" { + if metadata.LastInbound == metadata.InboundDetour { + return E.New("routing loop on detour: ", metadata.InboundDetour) + } + detour, loaded := r.inbound.Get(metadata.InboundDetour) + if !loaded { + return E.New("inbound detour not found: ", metadata.InboundDetour) + } + injectable, isInjectable := detour.(adapter.TCPInjectableInbound) + if !isInjectable { + return E.New("inbound detour is not TCP injectable: ", metadata.InboundDetour) + } + metadata.LastInbound = metadata.Inbound + metadata.Inbound = metadata.InboundDetour + metadata.InboundDetour = "" + injectable.NewConnectionEx(ctx, conn, metadata, onClose) + return nil + } + metadata.Network = N.NetworkTCP + switch metadata.Destination.Fqdn { + case mux.Destination.Fqdn: + return E.New("global multiplex is deprecated since sing-box v1.7.0, enable multiplex in Inbound fields instead.") + case vmess.MuxDestination.Fqdn: + return E.New("global multiplex (v2ray legacy) not supported since sing-box v1.7.0.") + case uot.MagicAddress: + return E.New("global UoT not supported since sing-box v1.7.0.") + case uot.LegacyMagicAddress: + return E.New("global UoT (legacy) not supported since sing-box v1.7.0.") + } + if deadline.NeedAdditionalReadDeadline(conn) { + conn = deadline.NewConn(conn) + } + selectedRule, _, buffers, _, err := r.matchRule(ctx, &metadata, false, false, conn, nil) + if err != nil { + return err + } + var selectedOutbound adapter.Outbound + if selectedRule != nil { + switch action := selectedRule.Action().(type) { + case *R.RuleActionRoute: + var loaded bool + selectedOutbound, loaded = r.outbound.Outbound(action.Outbound) + if !loaded { + buf.ReleaseMulti(buffers) + return E.New("outbound not found: ", action.Outbound) + } + if !common.Contains(selectedOutbound.Network(), N.NetworkTCP) { + buf.ReleaseMulti(buffers) + return E.New("TCP is not supported by outbound: ", selectedOutbound.Tag()) + } + case *R.RuleActionBypass: + if action.Outbound == "" { + break + } + var loaded bool + selectedOutbound, loaded = r.outbound.Outbound(action.Outbound) + if !loaded { + buf.ReleaseMulti(buffers) + return E.New("outbound not found: ", action.Outbound) + } + if !common.Contains(selectedOutbound.Network(), N.NetworkTCP) { + buf.ReleaseMulti(buffers) + return E.New("TCP is not supported by outbound: ", selectedOutbound.Tag()) + } + case *R.RuleActionReject: + buf.ReleaseMulti(buffers) + if action.Method == C.RuleActionRejectMethodReply { + return E.New("reject method `reply` is not supported for TCP connections") + } + return action.Error(ctx) + case *R.RuleActionHijackDNS: + for _, buffer := range buffers { + conn = bufio.NewCachedConn(conn, buffer) + } + N.CloseOnHandshakeFailure(conn, onClose, r.hijackDNSStream(ctx, conn, metadata)) + return nil + } + } + if selectedRule == nil { + defaultOutbound := r.outbound.Default() + if !common.Contains(defaultOutbound.Network(), N.NetworkTCP) { + buf.ReleaseMulti(buffers) + return E.New("TCP is not supported by default outbound: ", defaultOutbound.Tag()) + } + selectedOutbound = defaultOutbound + } + + for _, buffer := range buffers { + conn = bufio.NewCachedConn(conn, buffer) + } + for _, tracker := range r.trackers { + conn = tracker.RoutedConnection(ctx, conn, metadata, selectedRule, selectedOutbound) + } + if outboundHandler, isHandler := selectedOutbound.(adapter.ConnectionHandlerEx); isHandler { + outboundHandler.NewConnectionEx(ctx, conn, metadata, onClose) + } else { + r.connection.NewConnection(ctx, selectedOutbound, conn, metadata, onClose) + } + return nil +} + +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + done := make(chan interface{}) + err := r.routePacketConnection(ctx, conn, metadata, N.OnceClose(func(it error) { + close(done) + })) + if err != nil { + conn.Close() + if E.IsClosedOrCanceled(err) || R.IsRejected(err) { + r.logger.DebugContext(ctx, "connection closed: ", err) + } else { + r.logger.ErrorContext(ctx, err) + } + } + select { + case <-done: + case <-r.ctx.Done(): + } + return nil +} + +func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := r.routePacketConnection(ctx, conn, metadata, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + if E.IsClosedOrCanceled(err) || R.IsRejected(err) { + r.logger.DebugContext(ctx, "connection closed: ", err) + } else { + r.logger.ErrorContext(ctx, err) + } + } +} + +func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + //nolint:staticcheck + if metadata.InboundDetour != "" { + if metadata.LastInbound == metadata.InboundDetour { + return E.New("routing loop on detour: ", metadata.InboundDetour) + } + detour, loaded := r.inbound.Get(metadata.InboundDetour) + if !loaded { + return E.New("inbound detour not found: ", metadata.InboundDetour) + } + injectable, isInjectable := detour.(adapter.UDPInjectableInbound) + if !isInjectable { + return E.New("inbound detour is not UDP injectable: ", metadata.InboundDetour) + } + metadata.LastInbound = metadata.Inbound + metadata.Inbound = metadata.InboundDetour + metadata.InboundDetour = "" + injectable.NewPacketConnectionEx(ctx, conn, metadata, onClose) + return nil + } + // TODO: move to UoT + metadata.Network = N.NetworkUDP + + // Currently we don't have deadline usages for UDP connections + /*if deadline.NeedAdditionalReadDeadline(conn) { + conn = deadline.NewPacketConn(bufio.NewNetPacketConn(conn)) + }*/ + + selectedRule, _, _, packetBuffers, err := r.matchRule(ctx, &metadata, false, false, nil, conn) + if err != nil { + return err + } + var selectedOutbound adapter.Outbound + var selectReturn bool + if selectedRule != nil { + switch action := selectedRule.Action().(type) { + case *R.RuleActionRoute: + var loaded bool + selectedOutbound, loaded = r.outbound.Outbound(action.Outbound) + if !loaded { + N.ReleaseMultiPacketBuffer(packetBuffers) + return E.New("outbound not found: ", action.Outbound) + } + if !common.Contains(selectedOutbound.Network(), N.NetworkUDP) { + N.ReleaseMultiPacketBuffer(packetBuffers) + return E.New("UDP is not supported by outbound: ", selectedOutbound.Tag()) + } + case *R.RuleActionBypass: + if action.Outbound == "" { + break + } + var loaded bool + selectedOutbound, loaded = r.outbound.Outbound(action.Outbound) + if !loaded { + N.ReleaseMultiPacketBuffer(packetBuffers) + return E.New("outbound not found: ", action.Outbound) + } + if !common.Contains(selectedOutbound.Network(), N.NetworkUDP) { + N.ReleaseMultiPacketBuffer(packetBuffers) + return E.New("UDP is not supported by outbound: ", selectedOutbound.Tag()) + } + case *R.RuleActionReject: + N.ReleaseMultiPacketBuffer(packetBuffers) + if action.Method == C.RuleActionRejectMethodReply { + return E.New("reject method `reply` is not supported for UDP connections") + } + return action.Error(ctx) + case *R.RuleActionHijackDNS: + return r.hijackDNSPacket(ctx, conn, packetBuffers, metadata, onClose) + } + } + if selectedRule == nil || selectReturn { + defaultOutbound := r.outbound.Default() + if !common.Contains(defaultOutbound.Network(), N.NetworkUDP) { + N.ReleaseMultiPacketBuffer(packetBuffers) + return E.New("UDP is not supported by outbound: ", defaultOutbound.Tag()) + } + selectedOutbound = defaultOutbound + } + for _, buffer := range packetBuffers { + conn = bufio.NewCachedPacketConn(conn, buffer.Buffer, buffer.Destination) + N.PutPacketBuffer(buffer) + } + for _, tracker := range r.trackers { + conn = tracker.RoutedPacketConnection(ctx, conn, metadata, selectedRule, selectedOutbound) + } + if metadata.FakeIP { + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination) + } + if outboundHandler, isHandler := selectedOutbound.(adapter.PacketConnectionHandlerEx); isHandler { + outboundHandler.NewPacketConnectionEx(ctx, conn, metadata, onClose) + } else { + r.connection.NewPacketConnection(ctx, selectedOutbound, conn, metadata, onClose) + } + return nil +} + +func (r *Router) PreMatch(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration, supportBypass bool) (tun.DirectRouteDestination, error) { + selectedRule, _, _, _, err := r.matchRule(r.ctx, &metadata, true, supportBypass, nil, nil) + if err != nil { + return nil, err + } + var directRouteOutbound adapter.DirectRouteOutbound + if selectedRule != nil { + switch action := selectedRule.Action().(type) { + case *R.RuleActionReject: + switch metadata.Network { + case N.NetworkTCP: + if action.Method == C.RuleActionRejectMethodReply { + return nil, E.New("reject method `reply` is not supported for TCP connections") + } + case N.NetworkUDP: + if action.Method == C.RuleActionRejectMethodReply { + return nil, E.New("reject method `reply` is not supported for UDP connections") + } + } + return nil, action.Error(context.Background()) + case *R.RuleActionBypass: + if supportBypass { + return nil, &R.BypassedError{Cause: tun.ErrBypass} + } + if routeContext == nil { + return nil, nil + } + outbound, loaded := r.outbound.Outbound(action.Outbound) + if !loaded { + return nil, E.New("outbound not found: ", action.Outbound) + } + if !common.Contains(outbound.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by outbound: ", action.Outbound) + } + directRouteOutbound = outbound.(adapter.DirectRouteOutbound) + case *R.RuleActionRoute: + if routeContext == nil { + return nil, nil + } + outbound, loaded := r.outbound.Outbound(action.Outbound) + if !loaded { + return nil, E.New("outbound not found: ", action.Outbound) + } + if !common.Contains(outbound.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by outbound: ", action.Outbound) + } + directRouteOutbound = outbound.(adapter.DirectRouteOutbound) + } + } + if directRouteOutbound == nil { + if selectedRule != nil || metadata.Network != N.NetworkICMP { + return nil, nil + } + defaultOutbound := r.outbound.Default() + if !common.Contains(defaultOutbound.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by default outbound: ", defaultOutbound.Tag()) + } + directRouteOutbound = defaultOutbound.(adapter.DirectRouteOutbound) + } + if metadata.Destination.IsDomain() { + if len(metadata.DestinationAddresses) == 0 { + var strategy C.DomainStrategy + if metadata.Source.IsIPv4() { + strategy = C.DomainStrategyIPv4Only + } else { + strategy = C.DomainStrategyIPv6Only + } + err = r.actionResolve(r.ctx, &metadata, &R.RuleActionResolve{ + Strategy: strategy, + }) + if err != nil { + return nil, err + } + } + var newDestination netip.Addr + if metadata.Source.IsIPv4() { + for _, address := range metadata.DestinationAddresses { + if address.Is4() { + newDestination = address + break + } + } + } else { + for _, address := range metadata.DestinationAddresses { + if address.Is6() { + newDestination = address + break + } + } + } + if !newDestination.IsValid() { + if metadata.Source.IsIPv4() { + return nil, E.New("no IPv4 address found for domain: ", metadata.Destination.Fqdn) + } else { + return nil, E.New("no IPv6 address found for domain: ", metadata.Destination.Fqdn) + } + } + metadata.Destination = M.Socksaddr{ + Addr: newDestination, + } + routeContext = ping.NewContextDestinationWriter(routeContext, metadata.OriginDestination.Addr) + var routeDestination tun.DirectRouteDestination + routeDestination, err = directRouteOutbound.NewDirectRouteConnection(metadata, routeContext, timeout) + if err != nil { + return nil, err + } + return ping.NewDestinationWriter(routeDestination, newDestination), nil + } + return directRouteOutbound.NewDirectRouteConnection(metadata, routeContext, timeout) +} + +func (r *Router) matchRule( + ctx context.Context, metadata *adapter.InboundContext, preMatch bool, supportBypass bool, + inputConn net.Conn, inputPacketConn N.PacketConn, +) ( + selectedRule adapter.Rule, selectedRuleIndex int, + buffers []*buf.Buffer, packetBuffers []*N.PacketBuffer, fatalErr error, +) { + if r.processSearcher != nil && metadata.ProcessInfo == nil { + var originDestination netip.AddrPort + if metadata.OriginDestination.IsValid() { + originDestination = metadata.OriginDestination.AddrPort() + } else if metadata.Destination.IsIP() { + originDestination = metadata.Destination.AddrPort() + } + processInfo, fErr := r.findProcessInfoCached(ctx, metadata.Network, metadata.Source.AddrPort(), originDestination) + if fErr != nil { + r.logger.InfoContext(ctx, "failed to search process: ", fErr) + } else { + if processInfo.ProcessPath != "" { + if processInfo.UserName != "" { + r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath, ", user: ", processInfo.UserName) + } else if processInfo.UserId != -1 { + r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath, ", user id: ", processInfo.UserId) + } else { + r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath) + } + } else if len(processInfo.AndroidPackageNames) > 0 { + r.logger.InfoContext(ctx, "found package name: ", strings.Join(processInfo.AndroidPackageNames, ", ")) + } else if processInfo.UserId != -1 { + if processInfo.UserName != "" { + r.logger.InfoContext(ctx, "found user: ", processInfo.UserName) + } else { + r.logger.InfoContext(ctx, "found user id: ", processInfo.UserId) + } + } + metadata.ProcessInfo = processInfo + } + } + if r.neighborResolver != nil && metadata.SourceMACAddress == nil && metadata.Source.Addr.IsValid() { + mac, macFound := r.neighborResolver.LookupMAC(metadata.Source.Addr) + if macFound { + metadata.SourceMACAddress = mac + } + hostname, hostnameFound := r.neighborResolver.LookupHostname(metadata.Source.Addr) + if hostnameFound { + metadata.SourceHostname = hostname + if macFound { + r.logger.InfoContext(ctx, "found neighbor: ", mac, ", hostname: ", hostname) + } else { + r.logger.InfoContext(ctx, "found neighbor hostname: ", hostname) + } + } else if macFound { + r.logger.InfoContext(ctx, "found neighbor: ", mac) + } + } + if metadata.Destination.Addr.IsValid() && r.dnsTransport.FakeIP() != nil && r.dnsTransport.FakeIP().Store().Contains(metadata.Destination.Addr) { + domain, loaded := r.dnsTransport.FakeIP().Store().Lookup(metadata.Destination.Addr) + if !loaded { + fatalErr = E.New("missing fakeip record, try enable `experimental.cache_file`") + return + } + if domain != "" { + metadata.OriginDestination = metadata.Destination + metadata.Destination = M.Socksaddr{ + Fqdn: domain, + Port: metadata.Destination.Port, + } + metadata.FakeIP = true + r.logger.DebugContext(ctx, "found fakeip domain: ", domain) + } + } else if metadata.Domain == "" { + domain, loaded := r.dns.LookupReverseMapping(metadata.Destination.Addr) + if loaded { + metadata.Domain = domain + r.logger.DebugContext(ctx, "found reserve mapped domain: ", metadata.Domain) + } + } + if metadata.Destination.IsIPv4() { + metadata.IPVersion = 4 + } else if metadata.Destination.IsIPv6() { + metadata.IPVersion = 6 + } + +match: + for currentRuleIndex, currentRule := range r.rules { + metadata.ResetRuleCache() + if !currentRule.Match(metadata) { + continue + } + if !preMatch { + ruleDescription := currentRule.String() + if ruleDescription != "" { + r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + } else { + r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] => ", currentRule.Action()) + } + } else { + switch currentRule.Action().Type() { + case C.RuleActionTypeReject: + ruleDescription := currentRule.String() + if ruleDescription != "" { + r.logger.DebugContext(ctx, "pre-match[", currentRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + } else { + r.logger.DebugContext(ctx, "pre-match[", currentRuleIndex, "] => ", currentRule.Action()) + } + } + } + var routeOptions *R.RuleActionRouteOptions + switch action := currentRule.Action().(type) { + case *R.RuleActionRoute: + routeOptions = &action.RuleActionRouteOptions + case *R.RuleActionRouteOptions: + routeOptions = action + } + if routeOptions != nil { + // TODO: add nat + if (routeOptions.OverrideAddress.IsValid() || routeOptions.OverridePort > 0) && !metadata.RouteOriginalDestination.IsValid() { + metadata.RouteOriginalDestination = metadata.Destination + } + if routeOptions.OverrideAddress.IsValid() { + metadata.Destination = M.Socksaddr{ + Addr: routeOptions.OverrideAddress.Addr, + Port: metadata.Destination.Port, + Fqdn: routeOptions.OverrideAddress.Fqdn, + } + metadata.DestinationAddresses = nil + } + if routeOptions.OverridePort > 0 { + metadata.Destination = M.Socksaddr{ + Addr: metadata.Destination.Addr, + Port: routeOptions.OverridePort, + Fqdn: metadata.Destination.Fqdn, + } + } + if routeOptions.NetworkStrategy != nil { + metadata.NetworkStrategy = routeOptions.NetworkStrategy + } + if len(routeOptions.NetworkType) > 0 { + metadata.NetworkType = routeOptions.NetworkType + } + if len(routeOptions.FallbackNetworkType) > 0 { + metadata.FallbackNetworkType = routeOptions.FallbackNetworkType + } + if routeOptions.FallbackDelay != 0 { + metadata.FallbackDelay = routeOptions.FallbackDelay + } + if routeOptions.UDPDisableDomainUnmapping { + metadata.UDPDisableDomainUnmapping = true + } + if routeOptions.UDPConnect { + metadata.UDPConnect = true + } + if routeOptions.UDPTimeout > 0 { + metadata.UDPTimeout = routeOptions.UDPTimeout + } + if routeOptions.TLSFragment { + metadata.TLSFragment = true + metadata.TLSFragmentFallbackDelay = routeOptions.TLSFragmentFallbackDelay + } + if routeOptions.TLSRecordFragment { + metadata.TLSRecordFragment = true + } + } + switch action := currentRule.Action().(type) { + case *R.RuleActionSniff: + if !preMatch { + newBuffer, newPacketBuffers, newErr := r.actionSniff(ctx, metadata, action, inputConn, inputPacketConn, buffers, packetBuffers) + if newBuffer != nil { + buffers = append(buffers, newBuffer) + } else if len(newPacketBuffers) > 0 { + packetBuffers = append(packetBuffers, newPacketBuffers...) + } + if newErr != nil { + fatalErr = newErr + return + } + } else if metadata.Network != N.NetworkICMP { + selectedRule = currentRule + selectedRuleIndex = currentRuleIndex + break match + } + case *R.RuleActionResolve: + fatalErr = r.actionResolve(ctx, metadata, action) + if fatalErr != nil { + return + } + } + actionType := currentRule.Action().Type() + if actionType == C.RuleActionTypeRoute || + actionType == C.RuleActionTypeReject || + actionType == C.RuleActionTypeHijackDNS { + selectedRule = currentRule + selectedRuleIndex = currentRuleIndex + break match + } + if actionType == C.RuleActionTypeBypass { + bypassAction := currentRule.Action().(*R.RuleActionBypass) + if !supportBypass && bypassAction.Outbound == "" { + continue match + } + selectedRule = currentRule + selectedRuleIndex = currentRuleIndex + break match + } + } + return +} + +func (r *Router) actionSniff( + ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionSniff, + inputConn net.Conn, inputPacketConn N.PacketConn, inputBuffers []*buf.Buffer, inputPacketBuffers []*N.PacketBuffer, +) (buffer *buf.Buffer, packetBuffers []*N.PacketBuffer, fatalErr error) { + if sniff.Skip(metadata) { + r.logger.DebugContext(ctx, "sniff skipped due to port considered as server-first") + return + } else if metadata.Protocol != "" { + r.logger.DebugContext(ctx, "duplicate sniff skipped") + return + } + if inputConn != nil { + if len(action.StreamSniffers) == 0 && len(action.PacketSniffers) > 0 { + return + } else if slices.Equal(metadata.SnifferNames, action.SnifferNames) && metadata.SniffError != nil && !errors.Is(metadata.SniffError, sniff.ErrNeedMoreData) { + r.logger.DebugContext(ctx, "packet sniff skipped due to previous error: ", metadata.SniffError) + return + } + var streamSniffers []sniff.StreamSniffer + if len(action.StreamSniffers) > 0 { + streamSniffers = action.StreamSniffers + } else { + streamSniffers = []sniff.StreamSniffer{ + sniff.TLSClientHello, + sniff.HTTPHost, + sniff.StreamDomainNameQuery, + sniff.BitTorrent, + sniff.SSH, + sniff.RDP, + } + } + sniffBuffer := buf.NewPacket() + err := sniff.PeekStream( + ctx, + metadata, + inputConn, + inputBuffers, + sniffBuffer, + action.Timeout, + streamSniffers..., + ) + metadata.SnifferNames = action.SnifferNames + metadata.SniffError = err + if err == nil { + //goland:noinspection GoDeprecation + if action.OverrideDestination && M.IsDomainName(metadata.Domain) { + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.Domain, + Port: metadata.Destination.Port, + } + } + if metadata.Domain != "" && metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client) + } else if metadata.Domain != "" { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain) + } else { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol) + } + } + if !sniffBuffer.IsEmpty() { + buffer = sniffBuffer + } else { + sniffBuffer.Release() + } + } else if inputPacketConn != nil { + if len(action.PacketSniffers) == 0 && len(action.StreamSniffers) > 0 { + return + } else if slices.Equal(metadata.SnifferNames, action.SnifferNames) && metadata.SniffError != nil && !errors.Is(metadata.SniffError, sniff.ErrNeedMoreData) { + r.logger.DebugContext(ctx, "packet sniff skipped due to previous error: ", metadata.SniffError) + return + } + quicMoreData := func() bool { + return slices.Equal(metadata.SnifferNames, action.SnifferNames) && errors.Is(metadata.SniffError, sniff.ErrNeedMoreData) + } + var packetSniffers []sniff.PacketSniffer + if len(action.PacketSniffers) > 0 { + packetSniffers = action.PacketSniffers + } else { + packetSniffers = []sniff.PacketSniffer{ + sniff.DomainNameQuery, + sniff.QUICClientHello, + sniff.STUNMessage, + sniff.UTP, + sniff.UDPTracker, + sniff.DTLSRecord, + sniff.NTP, + } + } + var err error + for _, packetBuffer := range inputPacketBuffers { + if quicMoreData() { + err = sniff.PeekPacket( + ctx, + metadata, + packetBuffer.Buffer.Bytes(), + sniff.QUICClientHello, + ) + } else { + err = sniff.PeekPacket( + ctx, metadata, + packetBuffer.Buffer.Bytes(), + packetSniffers..., + ) + } + metadata.SnifferNames = action.SnifferNames + metadata.SniffError = err + if errors.Is(err, sniff.ErrNeedMoreData) { + // TODO: replace with generic message when there are more multi-packet protocols + r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello") + continue + } + goto finally + } + packetBuffers = inputPacketBuffers + for { + var ( + sniffBuffer = buf.NewPacket() + destination M.Socksaddr + done = make(chan struct{}) + ) + go func() { + sniffTimeout := C.ReadPayloadTimeout + if action.Timeout > 0 { + sniffTimeout = action.Timeout + } + inputPacketConn.SetReadDeadline(time.Now().Add(sniffTimeout)) + destination, err = inputPacketConn.ReadPacket(sniffBuffer) + inputPacketConn.SetReadDeadline(time.Time{}) + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + inputPacketConn.Close() + fatalErr = ctx.Err() + return + } + if err != nil { + sniffBuffer.Release() + if !errors.Is(err, context.DeadlineExceeded) { + fatalErr = err + return + } + } else { + if quicMoreData() { + err = sniff.PeekPacket( + ctx, + metadata, + sniffBuffer.Bytes(), + sniff.QUICClientHello, + ) + } else { + err = sniff.PeekPacket( + ctx, metadata, + sniffBuffer.Bytes(), + packetSniffers..., + ) + } + packetBuffer := N.NewPacketBuffer() + *packetBuffer = N.PacketBuffer{ + Buffer: sniffBuffer, + Destination: destination, + } + packetBuffers = append(packetBuffers, packetBuffer) + metadata.SnifferNames = action.SnifferNames + metadata.SniffError = err + if errors.Is(err, sniff.ErrNeedMoreData) { + // TODO: replace with generic message when there are more multi-packet protocols + r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello") + continue + } + } + goto finally + } + finally: + if err == nil { + //goland:noinspection GoDeprecation + if action.OverrideDestination && M.IsDomainName(metadata.Domain) { + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.Domain, + Port: metadata.Destination.Port, + } + } + if metadata.Domain != "" && metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client) + } else if metadata.Domain != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain) + } else if metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", client: ", metadata.Client) + } else { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol) + } + } + } + return +} + +func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionResolve) error { + if metadata.Destination.IsDomain() { + var transport adapter.DNSTransport + if action.Server != "" { + var loaded bool + transport, loaded = r.dnsTransport.Transport(action.Server) + if !loaded { + return E.New("DNS server not found: ", action.Server) + } + } + addresses, err := r.dns.Lookup(adapter.WithContext(ctx, metadata), metadata.Destination.Fqdn, adapter.DNSQueryOptions{ + Transport: transport, + Strategy: action.Strategy, + DisableCache: action.DisableCache, + DisableOptimisticCache: action.DisableOptimisticCache, + RewriteTTL: action.RewriteTTL, + ClientSubnet: action.ClientSubnet, + }) + if err != nil { + return err + } + metadata.DestinationAddresses = addresses + r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") + } + return nil +} diff --git a/route/router.go b/route/router.go new file mode 100644 index 00000000..03546b2a --- /dev/null +++ b/route/router.go @@ -0,0 +1,284 @@ +package route + +import ( + "context" + "os" + "runtime" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/process" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + R "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" +) + +var _ adapter.Router = (*Router)(nil) + +type Router struct { + ctx context.Context + logger log.ContextLogger + inbound adapter.InboundManager + outbound adapter.OutboundManager + dns adapter.DNSRouter + dnsTransport adapter.DNSTransportManager + connection adapter.ConnectionManager + network adapter.NetworkManager + rules []adapter.Rule + needFindProcess bool + needFindNeighbor bool + leaseFiles []string + ruleSets []adapter.RuleSet + ruleSetMap map[string]adapter.RuleSet + processSearcher process.Searcher + processCache freelru.Cache[processCacheKey, processCacheEntry] + neighborResolver adapter.NeighborResolver + pauseManager pause.Manager + trackers []adapter.ConnectionTracker + platformInterface adapter.PlatformInterface + started bool +} + +func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions) *Router { + return &Router{ + ctx: ctx, + logger: logFactory.NewLogger("router"), + inbound: service.FromContext[adapter.InboundManager](ctx), + outbound: service.FromContext[adapter.OutboundManager](ctx), + dns: service.FromContext[adapter.DNSRouter](ctx), + dnsTransport: service.FromContext[adapter.DNSTransportManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + network: service.FromContext[adapter.NetworkManager](ctx), + rules: make([]adapter.Rule, 0, len(options.Rules)), + ruleSetMap: make(map[string]adapter.RuleSet), + needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, + needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || options.FindNeighbor, + leaseFiles: options.DHCPLeaseFiles, + pauseManager: service.FromContext[pause.Manager](ctx), + platformInterface: service.FromContext[adapter.PlatformInterface](ctx), + } +} + +func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) error { + for i, options := range rules { + err := R.ValidateNoNestedRuleActions(options) + if err != nil { + return E.Cause(err, "parse rule[", i, "]") + } + rule, err := R.NewRule(r.ctx, r.logger, options, false) + if err != nil { + return E.Cause(err, "parse rule[", i, "]") + } + r.rules = append(r.rules, rule) + } + for i, options := range ruleSets { + if _, exists := r.ruleSetMap[options.Tag]; exists { + return E.New("duplicate rule-set tag: ", options.Tag) + } + ruleSet, err := R.NewRuleSet(r.ctx, r.logger, options) + if err != nil { + return E.Cause(err, "parse rule-set[", i, "]") + } + r.ruleSets = append(r.ruleSets, ruleSet) + r.ruleSetMap[options.Tag] = ruleSet + } + return nil +} + +func (r *Router) Start(stage adapter.StartStage) error { + monitor := taskmonitor.New(r.logger, C.StartTimeout) + switch stage { + case adapter.StartStateStart: + var cacheContext *adapter.HTTPStartContext + if len(r.ruleSets) > 0 { + monitor.Start("initialize rule-set") + cacheContext = adapter.NewHTTPStartContext(r.ctx) + var ruleSetStartGroup task.Group + for i, ruleSet := range r.ruleSets { + ruleSetInPlace := ruleSet + ruleSetStartGroup.Append0(func(ctx context.Context) error { + err := ruleSetInPlace.StartContext(ctx, cacheContext) + if err != nil { + return E.Cause(err, "initialize rule-set[", i, "]") + } + return nil + }) + } + ruleSetStartGroup.Concurrency(5) + ruleSetStartGroup.FastFail() + err := ruleSetStartGroup.Run(r.ctx) + monitor.Finish() + if err != nil { + return err + } + } + if cacheContext != nil { + cacheContext.Close() + } + r.network.Initialize(r.ruleSets) + needFindProcess := r.needFindProcess + needFindNeighbor := r.needFindNeighbor + for _, ruleSet := range r.ruleSets { + metadata := ruleSet.Metadata() + if metadata.ContainsProcessRule { + needFindProcess = true + } + } + if C.IsAndroid && r.platformInterface != nil { + needFindProcess = true + } + r.needFindProcess = needFindProcess + if needFindProcess { + if r.platformInterface != nil && r.platformInterface.UsePlatformConnectionOwnerFinder() { + r.processSearcher = newPlatformSearcher(r.platformInterface) + } else { + monitor.Start("initialize process searcher") + searcher, err := process.NewSearcher(process.Config{ + Logger: r.logger, + PackageManager: r.network.PackageManager(), + }) + monitor.Finish() + if err != nil { + if err != os.ErrInvalid { + r.logger.Warn(E.Cause(err, "create process searcher")) + } + } else { + r.processSearcher = searcher + } + } + } + if r.processSearcher != nil { + processCache := common.Must1(freelru.NewSharded[processCacheKey, processCacheEntry](256, maphash.NewHasher[processCacheKey]().Hash32)) + processCache.SetLifetime(200 * time.Millisecond) + r.processCache = processCache + } + r.needFindNeighbor = needFindNeighbor + if needFindNeighbor { + if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() { + monitor.Start("initialize neighbor resolver") + resolver := newPlatformNeighborResolver(r.logger, r.platformInterface) + err := resolver.Start() + monitor.Finish() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } else { + monitor.Start("initialize neighbor resolver") + resolver, err := newNeighborResolver(r.logger, r.leaseFiles) + monitor.Finish() + if err != nil { + if err != os.ErrInvalid { + r.logger.Error(E.Cause(err, "create neighbor resolver")) + } + } else { + err = resolver.Start() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } + } + } + case adapter.StartStatePostStart: + for i, rule := range r.rules { + monitor.Start("initialize rule[", i, "]") + err := rule.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "initialize rule[", i, "]") + } + } + for _, ruleSet := range r.ruleSets { + monitor.Start("post start rule_set[", ruleSet.Name(), "]") + err := ruleSet.PostStart() + monitor.Finish() + if err != nil { + return E.Cause(err, "post start rule_set[", ruleSet.Name(), "]") + } + } + r.started = true + return nil + case adapter.StartStateStarted: + for _, ruleSet := range r.ruleSets { + ruleSet.Cleanup() + } + runtime.GC() + } + return nil +} + +func (r *Router) Close() error { + monitor := taskmonitor.New(r.logger, C.StopTimeout) + var err error + if r.neighborResolver != nil { + monitor.Start("close neighbor resolver") + err = E.Append(err, r.neighborResolver.Close(), func(closeErr error) error { + return E.Cause(closeErr, "close neighbor resolver") + }) + monitor.Finish() + } + for i, rule := range r.rules { + monitor.Start("close rule[", i, "]") + err = E.Append(err, rule.Close(), func(err error) error { + return E.Cause(err, "close rule[", i, "]") + }) + monitor.Finish() + } + for i, ruleSet := range r.ruleSets { + monitor.Start("close rule-set[", i, "]") + err = E.Append(err, ruleSet.Close(), func(err error) error { + return E.Cause(err, "close rule-set[", i, "]") + }) + monitor.Finish() + } + if r.processSearcher != nil { + monitor.Start("close process searcher") + err = E.Append(err, r.processSearcher.Close(), func(err error) error { + return E.Cause(err, "close process searcher") + }) + monitor.Finish() + } + return err +} + +func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) { + ruleSet, loaded := r.ruleSetMap[tag] + return ruleSet, loaded +} + +func (r *Router) Rules() []adapter.Rule { + return r.rules +} + +func (r *Router) AppendTracker(tracker adapter.ConnectionTracker) { + r.trackers = append(r.trackers, tracker) +} + +func (r *Router) NeedFindProcess() bool { + return r.needFindProcess +} + +func (r *Router) NeedFindNeighbor() bool { + return r.needFindNeighbor +} + +func (r *Router) NeighborResolver() adapter.NeighborResolver { + return r.neighborResolver +} + +func (r *Router) ResetNetwork() { + r.network.ResetNetwork() + r.dns.ResetNetwork() +} diff --git a/route/rule/match_state.go b/route/rule/match_state.go new file mode 100644 index 00000000..feac8418 --- /dev/null +++ b/route/rule/match_state.go @@ -0,0 +1,126 @@ +package rule + +import "github.com/sagernet/sing-box/adapter" + +type ruleMatchState uint8 + +const ( + ruleMatchSourceAddress ruleMatchState = 1 << iota + ruleMatchSourcePort + ruleMatchDestinationAddress + ruleMatchDestinationPort +) + +type ruleMatchStateSet uint16 + +func singleRuleMatchState(state ruleMatchState) ruleMatchStateSet { + return 1 << state +} + +func emptyRuleMatchState() ruleMatchStateSet { + return singleRuleMatchState(0) +} + +func (s ruleMatchStateSet) isEmpty() bool { + return s == 0 +} + +func (s ruleMatchStateSet) contains(state ruleMatchState) bool { + return s&(1< 0 +} + +func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { + return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 +} + +func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { + return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) +} + +func (r *abstractDefaultRule) requiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { + return len(r.destinationAddressItems) > 0 || r.destinationIPCIDRMatchesDestination(metadata) +} + +func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesWithBase(metadata, 0) +} + +func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) ruleMatchStateSet { + if len(r.allItems) == 0 { + return emptyRuleMatchState().withBase(inheritedBase) + } + evaluationBase := inheritedBase + if r.invert { + evaluationBase = 0 + } + baseState := evaluationBase + if len(r.sourceAddressItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.sourceAddressItems, metadata) { + baseState |= ruleMatchSourceAddress + } + } + if r.destinationIPCIDRMatchesSource(metadata) && !baseState.has(ruleMatchSourceAddress) { + metadata.DidMatch = true + if matchAnyItem(r.destinationIPCIDRItems, metadata) { + baseState |= ruleMatchSourceAddress + } + } else if r.destinationIPCIDRMatchesSource(metadata) { + metadata.DidMatch = true + } + if len(r.sourcePortItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.sourcePortItems, metadata) { + baseState |= ruleMatchSourcePort + } + } + if len(r.destinationAddressItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.destinationAddressItems, metadata) { + baseState |= ruleMatchDestinationAddress + } + } + if r.destinationIPCIDRMatchesDestination(metadata) && !baseState.has(ruleMatchDestinationAddress) { + metadata.DidMatch = true + if matchAnyItem(r.destinationIPCIDRItems, metadata) { + baseState |= ruleMatchDestinationAddress + } + } else if r.destinationIPCIDRMatchesDestination(metadata) { + metadata.DidMatch = true + } + if len(r.destinationPortItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.destinationPortItems, metadata) { + baseState |= ruleMatchDestinationPort + } + } + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return r.invertedFailure(inheritedBase) + } + } + var stateSet ruleMatchStateSet + if r.ruleSetItem != nil { + metadata.DidMatch = true + stateSet = matchRuleItemStatesWithBase(r.ruleSetItem, metadata, baseState) + } else { + stateSet = singleRuleMatchState(baseState) + } + stateSet = stateSet.filter(func(state ruleMatchState) bool { + if r.requiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { + return false + } + if len(r.sourcePortItems) > 0 && !state.has(ruleMatchSourcePort) { + return false + } + if r.requiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { + return false + } + if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { + return false + } + return true + }) + if stateSet.isEmpty() { + return r.invertedFailure(inheritedBase) + } + if r.invert { + if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { + return emptyRuleMatchState().withBase(inheritedBase) + } + return 0 + } + return stateSet +} + +func (r *abstractDefaultRule) invertedFailure(base ruleMatchState) ruleMatchStateSet { + if r.invert { + return emptyRuleMatchState().withBase(base) + } + return 0 +} + +func (r *abstractDefaultRule) Action() adapter.RuleAction { + return r.action +} + +func (r *abstractDefaultRule) String() string { + if !r.invert { + return strings.Join(F.MapToString(r.allItems), " ") + } else { + return "!(" + strings.Join(F.MapToString(r.allItems), " ") + ")" + } +} + +type abstractLogicalRule struct { + rules []adapter.HeadlessRule + mode string + invert bool + action adapter.RuleAction +} + +func (r *abstractLogicalRule) Type() string { + return C.RuleTypeLogical +} + +func (r *abstractLogicalRule) Start() error { + for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (interface { + Start() error + }, bool, + ) { + rule, loaded := it.(interface { + Start() error + }) + return rule, loaded + }) { + err := rule.Start() + if err != nil { + return err + } + } + return nil +} + +func (r *abstractLogicalRule) Close() error { + for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (io.Closer, bool) { + rule, loaded := it.(io.Closer) + return rule, loaded + }) { + err := rule.Close() + if err != nil { + return err + } + } + return nil +} + +func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { + return !r.matchStates(metadata).isEmpty() +} + +func (r *abstractLogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesWithBase(metadata, 0) +} + +func (r *abstractLogicalRule) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + evaluationBase := base + if r.invert { + evaluationBase = 0 + } + var stateSet ruleMatchStateSet + if r.mode == C.LogicalTypeAnd { + stateSet = emptyRuleMatchState().withBase(evaluationBase) + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + nestedStateSet := matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase) + if nestedStateSet.isEmpty() { + if r.invert { + return emptyRuleMatchState().withBase(base) + } + return 0 + } + stateSet = stateSet.combine(nestedStateSet) + } + } else { + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) + } + if stateSet.isEmpty() { + if r.invert { + return emptyRuleMatchState().withBase(base) + } + return 0 + } + } + if r.invert { + return 0 + } + return stateSet +} + +func (r *abstractLogicalRule) Action() adapter.RuleAction { + return r.action +} + +func (r *abstractLogicalRule) String() string { + var op string + switch r.mode { + case C.LogicalTypeAnd: + op = "&&" + case C.LogicalTypeOr: + op = "||" + } + if !r.invert { + return strings.Join(F.MapToString(r.rules), " "+op+" ") + } else { + return "!(" + strings.Join(F.MapToString(r.rules), " "+op+" ") + ")" + } +} + +func matchAnyItem(items []RuleItem, metadata *adapter.InboundContext) bool { + return common.Any(items, func(it RuleItem) bool { + return it.Match(metadata) + }) +} + +func (s ruleMatchState) has(target ruleMatchState) bool { + return s&target != 0 +} diff --git a/route/rule/rule_abstract_test.go b/route/rule/rule_abstract_test.go new file mode 100644 index 00000000..ace3dec6 --- /dev/null +++ b/route/rule/rule_abstract_test.go @@ -0,0 +1,157 @@ +package rule + +import ( + "context" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/x/list" + + "github.com/stretchr/testify/require" + "go4.org/netipx" +) + +type fakeRuleSet struct { + matched bool +} + +func (f *fakeRuleSet) Name() string { + return "fake-rule-set" +} + +func (f *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { + return nil +} + +func (f *fakeRuleSet) PostStart() error { + return nil +} + +func (f *fakeRuleSet) Metadata() adapter.RuleSetMetadata { + return adapter.RuleSetMetadata{} +} + +func (f *fakeRuleSet) ExtractIPSet() []*netipx.IPSet { + return nil +} + +func (f *fakeRuleSet) IncRef() {} + +func (f *fakeRuleSet) DecRef() {} + +func (f *fakeRuleSet) Cleanup() {} + +func (f *fakeRuleSet) RegisterCallback(adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + return nil +} + +func (f *fakeRuleSet) UnregisterCallback(*list.Element[adapter.RuleSetUpdateCallback]) {} + +func (f *fakeRuleSet) Close() error { + return nil +} + +func (f *fakeRuleSet) Match(*adapter.InboundContext) bool { + return f.matched +} + +func (f *fakeRuleSet) String() string { + return "fake-rule-set" +} + +type fakeRuleItem struct { + matched bool +} + +func (f *fakeRuleItem) Match(*adapter.InboundContext) bool { + return f.matched +} + +func (f *fakeRuleItem) String() string { + return "fake-rule-item" +} + +func newRuleSetOnlyRule(ruleSetMatched bool, invert bool) *DefaultRule { + ruleSetItem := &RuleSetItem{ + setList: []adapter.RuleSet{&fakeRuleSet{matched: ruleSetMatched}}, + } + return &DefaultRule{ + abstractDefaultRule: abstractDefaultRule{ + ruleSetItem: ruleSetItem, + allItems: []RuleItem{ruleSetItem}, + invert: invert, + }, + } +} + +func newSingleItemRule(matched bool) *DefaultRule { + item := &fakeRuleItem{matched: matched} + return &DefaultRule{ + abstractDefaultRule: abstractDefaultRule{ + items: []RuleItem{item}, + allItems: []RuleItem{item}, + }, + } +} + +func TestAbstractDefaultRule_RuleSetOnly_InvertFalse(t *testing.T) { + t.Parallel() + require.True(t, newRuleSetOnlyRule(true, false).Match(&adapter.InboundContext{})) + require.False(t, newRuleSetOnlyRule(false, false).Match(&adapter.InboundContext{})) +} + +func TestAbstractDefaultRule_RuleSetOnly_InvertTrue(t *testing.T) { + t.Parallel() + require.False(t, newRuleSetOnlyRule(true, true).Match(&adapter.InboundContext{})) + require.True(t, newRuleSetOnlyRule(false, true).Match(&adapter.InboundContext{})) +} + +func TestAbstractLogicalRule_And_WithRuleSetInvert(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + aMatched bool + ruleSetBMatch bool + expected bool + }{ + { + name: "A true B true", + aMatched: true, + ruleSetBMatch: true, + expected: false, + }, + { + name: "A true B false", + aMatched: true, + ruleSetBMatch: false, + expected: true, + }, + { + name: "A false B true", + aMatched: false, + ruleSetBMatch: true, + expected: false, + }, + { + name: "A false B false", + aMatched: false, + ruleSetBMatch: false, + expected: false, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + logicalRule := &abstractLogicalRule{ + mode: C.LogicalTypeAnd, + rules: []adapter.HeadlessRule{ + newSingleItemRule(testCase.aMatched), + newRuleSetOnlyRule(testCase.ruleSetBMatch, true), + }, + } + require.Equal(t, testCase.expected, logicalRule.Match(&adapter.InboundContext{})) + }) + } +} diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go new file mode 100644 index 00000000..ea239b68 --- /dev/null +++ b/route/rule/rule_action.go @@ -0,0 +1,610 @@ +package rule + +import ( + "context" + "errors" + "net/netip" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/sniff" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/miekg/dns" +) + +func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action option.RuleAction) (adapter.RuleAction, error) { + switch action.Action { + case "": + return nil, nil + case C.RuleActionTypeRoute: + return &RuleActionRoute{ + Outbound: action.RouteOptions.Outbound, + RuleActionRouteOptions: RuleActionRouteOptions{ + OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptions.OverrideAddress, 0), + OverridePort: action.RouteOptions.OverridePort, + NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptions.NetworkStrategy), + FallbackDelay: time.Duration(action.RouteOptions.FallbackDelay), + UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping, + UDPConnect: action.RouteOptions.UDPConnect, + TLSFragment: action.RouteOptions.TLSFragment, + TLSFragmentFallbackDelay: time.Duration(action.RouteOptions.TLSFragmentFallbackDelay), + TLSRecordFragment: action.RouteOptions.TLSRecordFragment, + }, + }, nil + case C.RuleActionTypeRouteOptions: + return &RuleActionRouteOptions{ + OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptionsOptions.OverrideAddress, 0), + OverridePort: action.RouteOptionsOptions.OverridePort, + NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptionsOptions.NetworkStrategy), + FallbackDelay: time.Duration(action.RouteOptionsOptions.FallbackDelay), + UDPDisableDomainUnmapping: action.RouteOptionsOptions.UDPDisableDomainUnmapping, + UDPConnect: action.RouteOptionsOptions.UDPConnect, + UDPTimeout: time.Duration(action.RouteOptionsOptions.UDPTimeout), + TLSFragment: action.RouteOptionsOptions.TLSFragment, + TLSFragmentFallbackDelay: time.Duration(action.RouteOptionsOptions.TLSFragmentFallbackDelay), + TLSRecordFragment: action.RouteOptionsOptions.TLSRecordFragment, + }, nil + case C.RuleActionTypeBypass: + return &RuleActionBypass{ + Outbound: action.BypassOptions.Outbound, + RuleActionRouteOptions: RuleActionRouteOptions{ + OverrideAddress: M.ParseSocksaddrHostPort(action.BypassOptions.OverrideAddress, 0), + OverridePort: action.BypassOptions.OverridePort, + NetworkStrategy: (*C.NetworkStrategy)(action.BypassOptions.NetworkStrategy), + FallbackDelay: time.Duration(action.BypassOptions.FallbackDelay), + UDPDisableDomainUnmapping: action.BypassOptions.UDPDisableDomainUnmapping, + UDPConnect: action.BypassOptions.UDPConnect, + TLSFragment: action.BypassOptions.TLSFragment, + TLSFragmentFallbackDelay: time.Duration(action.BypassOptions.TLSFragmentFallbackDelay), + TLSRecordFragment: action.BypassOptions.TLSRecordFragment, + }, + }, nil + case C.RuleActionTypeDirect: + directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false) + if err != nil { + return nil, err + } + var description string + descriptions := action.DirectOptions.Descriptions() + switch len(descriptions) { + case 0: + case 1: + description = F.ToString("(", descriptions[0], ")") + case 2: + description = F.ToString("(", descriptions[0], ",", descriptions[1], ")") + default: + description = F.ToString("(", descriptions[0], ",", descriptions[1], ",...)") + } + return &RuleActionDirect{ + Dialer: directDialer, + description: description, + }, nil + case C.RuleActionTypeReject: + return &RuleActionReject{ + Method: action.RejectOptions.Method, + NoDrop: action.RejectOptions.NoDrop, + logger: logger, + }, nil + case C.RuleActionTypeHijackDNS: + return &RuleActionHijackDNS{}, nil + case C.RuleActionTypeSniff: + sniffAction := &RuleActionSniff{ + SnifferNames: action.SniffOptions.Sniffer, + Timeout: time.Duration(action.SniffOptions.Timeout), + } + return sniffAction, sniffAction.build() + case C.RuleActionTypeResolve: + return &RuleActionResolve{ + Server: action.ResolveOptions.Server, + Strategy: C.DomainStrategy(action.ResolveOptions.Strategy), + DisableCache: action.ResolveOptions.DisableCache, + DisableOptimisticCache: action.ResolveOptions.DisableOptimisticCache, + RewriteTTL: action.ResolveOptions.RewriteTTL, + ClientSubnet: action.ResolveOptions.ClientSubnet.Build(netip.Prefix{}), + }, nil + default: + panic(F.ToString("unknown rule action: ", action.Action)) + } +} + +func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) adapter.RuleAction { + switch action.Action { + case "": + return nil + case C.RuleActionTypeRoute: + return &RuleActionDNSRoute{ + Server: action.RouteOptions.Server, + RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ + Strategy: C.DomainStrategy(action.RouteOptions.Strategy), + DisableCache: action.RouteOptions.DisableCache, + DisableOptimisticCache: action.RouteOptions.DisableOptimisticCache, + RewriteTTL: action.RouteOptions.RewriteTTL, + ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + }, + } + case C.RuleActionTypeEvaluate: + return &RuleActionEvaluate{ + Server: action.RouteOptions.Server, + RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ + Strategy: C.DomainStrategy(action.RouteOptions.Strategy), + DisableCache: action.RouteOptions.DisableCache, + DisableOptimisticCache: action.RouteOptions.DisableOptimisticCache, + RewriteTTL: action.RouteOptions.RewriteTTL, + ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + }, + } + case C.RuleActionTypeRespond: + return &RuleActionRespond{} + case C.RuleActionTypeRouteOptions: + return &RuleActionDNSRouteOptions{ + Strategy: C.DomainStrategy(action.RouteOptionsOptions.Strategy), + DisableCache: action.RouteOptionsOptions.DisableCache, + DisableOptimisticCache: action.RouteOptionsOptions.DisableOptimisticCache, + RewriteTTL: action.RouteOptionsOptions.RewriteTTL, + ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptionsOptions.ClientSubnet)), + } + case C.RuleActionTypeReject: + return &RuleActionReject{ + Method: action.RejectOptions.Method, + NoDrop: action.RejectOptions.NoDrop, + logger: logger, + } + case C.RuleActionTypePredefined: + return &RuleActionPredefined{ + Rcode: action.PredefinedOptions.Rcode.Build(), + Answer: common.Map(action.PredefinedOptions.Answer, option.DNSRecordOptions.Build), + Ns: common.Map(action.PredefinedOptions.Ns, option.DNSRecordOptions.Build), + Extra: common.Map(action.PredefinedOptions.Extra, option.DNSRecordOptions.Build), + } + default: + panic(F.ToString("unknown rule action: ", action.Action)) + } +} + +type RuleActionRoute struct { + Outbound string + RuleActionRouteOptions +} + +func (r *RuleActionRoute) Type() string { + return C.RuleActionTypeRoute +} + +func (r *RuleActionRoute) String() string { + var descriptions []string + descriptions = append(descriptions, r.Outbound) + descriptions = append(descriptions, r.Descriptions()...) + return F.ToString("route(", strings.Join(descriptions, ","), ")") +} + +type RuleActionBypass struct { + Outbound string + RuleActionRouteOptions +} + +func (r *RuleActionBypass) Type() string { + return C.RuleActionTypeBypass +} + +func (r *RuleActionBypass) String() string { + if r.Outbound == "" { + return "bypass()" + } + var descriptions []string + descriptions = append(descriptions, r.Outbound) + descriptions = append(descriptions, r.Descriptions()...) + return F.ToString("bypass(", strings.Join(descriptions, ","), ")") +} + +type RuleActionRouteOptions struct { + OverrideAddress M.Socksaddr + OverridePort uint16 + NetworkStrategy *C.NetworkStrategy + NetworkType []C.InterfaceType + FallbackNetworkType []C.InterfaceType + FallbackDelay time.Duration + UDPDisableDomainUnmapping bool + UDPConnect bool + UDPTimeout time.Duration + TLSFragment bool + TLSFragmentFallbackDelay time.Duration + TLSRecordFragment bool +} + +func (r *RuleActionRouteOptions) Type() string { + return C.RuleActionTypeRouteOptions +} + +func (r *RuleActionRouteOptions) String() string { + return F.ToString("route-options(", strings.Join(r.Descriptions(), ","), ")") +} + +func (r *RuleActionRouteOptions) Descriptions() []string { + var descriptions []string + if r.OverrideAddress.IsValid() { + descriptions = append(descriptions, F.ToString("override-address=", r.OverrideAddress.AddrString())) + } + if r.OverridePort > 0 { + descriptions = append(descriptions, F.ToString("override-port=", r.OverridePort)) + } + if r.NetworkStrategy != nil { + descriptions = append(descriptions, F.ToString("network-strategy=", r.NetworkStrategy)) + } + if r.NetworkType != nil { + descriptions = append(descriptions, F.ToString("network-type=", strings.Join(common.Map(r.NetworkType, C.InterfaceType.String), ","))) + } + if r.FallbackNetworkType != nil { + descriptions = append(descriptions, F.ToString("fallback-network-type=", strings.Join(common.Map(r.FallbackNetworkType, C.InterfaceType.String), ","))) + } + if r.FallbackDelay > 0 { + descriptions = append(descriptions, F.ToString("fallback-delay=", r.FallbackDelay.String())) + } + if r.UDPDisableDomainUnmapping { + descriptions = append(descriptions, "udp-disable-domain-unmapping") + } + if r.UDPConnect { + descriptions = append(descriptions, "udp-connect") + } + if r.UDPTimeout > 0 { + descriptions = append(descriptions, "udp-timeout") + } + if r.TLSFragment { + descriptions = append(descriptions, "tls-fragment") + } + if r.TLSFragmentFallbackDelay > 0 { + descriptions = append(descriptions, F.ToString("tls-fragment-fallback-delay=", r.TLSFragmentFallbackDelay.String())) + } + if r.TLSRecordFragment { + descriptions = append(descriptions, "tls-record-fragment") + } + return descriptions +} + +type RuleActionDNSRoute struct { + Server string + RuleActionDNSRouteOptions +} + +func (r *RuleActionDNSRoute) Type() string { + return C.RuleActionTypeRoute +} + +func (r *RuleActionDNSRoute) String() string { + return formatDNSRouteAction("route", r.Server, r.RuleActionDNSRouteOptions) +} + +type RuleActionEvaluate struct { + Server string + RuleActionDNSRouteOptions +} + +func (r *RuleActionEvaluate) Type() string { + return C.RuleActionTypeEvaluate +} + +func (r *RuleActionEvaluate) String() string { + return formatDNSRouteAction("evaluate", r.Server, r.RuleActionDNSRouteOptions) +} + +type RuleActionRespond struct{} + +func (r *RuleActionRespond) Type() string { + return C.RuleActionTypeRespond +} + +func (r *RuleActionRespond) String() string { + return "respond" +} + +func formatDNSRouteAction(action string, server string, options RuleActionDNSRouteOptions) string { + var descriptions []string + descriptions = append(descriptions, server) + if options.DisableCache { + descriptions = append(descriptions, "disable-cache") + } + if options.DisableOptimisticCache { + descriptions = append(descriptions, "disable-optimistic-cache") + } + if options.RewriteTTL != nil { + descriptions = append(descriptions, F.ToString("rewrite-ttl=", *options.RewriteTTL)) + } + if options.ClientSubnet.IsValid() { + descriptions = append(descriptions, F.ToString("client-subnet=", options.ClientSubnet)) + } + return F.ToString(action, "(", strings.Join(descriptions, ","), ")") +} + +type RuleActionDNSRouteOptions struct { + Strategy C.DomainStrategy + DisableCache bool + DisableOptimisticCache bool + RewriteTTL *uint32 + ClientSubnet netip.Prefix +} + +func (r *RuleActionDNSRouteOptions) Type() string { + return C.RuleActionTypeRouteOptions +} + +func (r *RuleActionDNSRouteOptions) String() string { + var descriptions []string + if r.DisableCache { + descriptions = append(descriptions, "disable-cache") + } + if r.DisableOptimisticCache { + descriptions = append(descriptions, "disable-optimistic-cache") + } + if r.RewriteTTL != nil { + descriptions = append(descriptions, F.ToString("rewrite-ttl=", *r.RewriteTTL)) + } + if r.ClientSubnet.IsValid() { + descriptions = append(descriptions, F.ToString("client-subnet=", r.ClientSubnet)) + } + return F.ToString("route-options(", strings.Join(descriptions, ","), ")") +} + +type RuleActionDirect struct { + Dialer N.Dialer + description string +} + +func (r *RuleActionDirect) Type() string { + return C.RuleActionTypeDirect +} + +func (r *RuleActionDirect) String() string { + return "direct" + r.description +} + +type RejectedError struct { + Cause error +} + +func (r *RejectedError) Error() string { + return "rejected" +} + +func (r *RejectedError) Unwrap() error { + return r.Cause +} + +func IsRejected(err error) bool { + var rejected *RejectedError + return errors.As(err, &rejected) +} + +type BypassedError struct { + Cause error +} + +func (b *BypassedError) Error() string { + return "bypassed" +} + +func (b *BypassedError) Unwrap() error { + return b.Cause +} + +func IsBypassed(err error) bool { + var bypassed *BypassedError + return errors.As(err, &bypassed) +} + +type RuleActionReject struct { + Method string + NoDrop bool + logger logger.ContextLogger + dropAccess sync.Mutex + dropCounter []time.Time +} + +func (r *RuleActionReject) Type() string { + return C.RuleActionTypeReject +} + +func (r *RuleActionReject) String() string { + if r.Method == C.RuleActionRejectMethodDefault { + return "reject" + } + return F.ToString("reject(", r.Method, ")") +} + +func (r *RuleActionReject) Error(ctx context.Context) error { + var returnErr error + switch r.Method { + case C.RuleActionRejectMethodDefault: + returnErr = &RejectedError{tun.ErrReset} + case C.RuleActionRejectMethodDrop: + return &RejectedError{tun.ErrDrop} + case C.RuleActionRejectMethodReply: + return nil + default: + panic(F.ToString("unknown reject method: ", r.Method)) + } + if r.NoDrop { + return returnErr + } + r.dropAccess.Lock() + defer r.dropAccess.Unlock() + timeNow := time.Now() + r.dropCounter = common.Filter(r.dropCounter, func(t time.Time) bool { + return timeNow.Sub(t) <= 30*time.Second + }) + r.dropCounter = append(r.dropCounter, timeNow) + if len(r.dropCounter) > 50 { + if ctx != nil { + r.logger.DebugContext(ctx, "dropped due to flooding") + } + return &RejectedError{tun.ErrDrop} + } + return returnErr +} + +type RuleActionHijackDNS struct{} + +func (r *RuleActionHijackDNS) Type() string { + return C.RuleActionTypeHijackDNS +} + +func (r *RuleActionHijackDNS) String() string { + return "hijack-dns" +} + +type RuleActionSniff struct { + SnifferNames []string + StreamSniffers []sniff.StreamSniffer + PacketSniffers []sniff.PacketSniffer + Timeout time.Duration + // Deprecated + OverrideDestination bool +} + +func (r *RuleActionSniff) Type() string { + return C.RuleActionTypeSniff +} + +func (r *RuleActionSniff) build() error { + for _, name := range r.SnifferNames { + switch name { + case C.ProtocolTLS: + r.StreamSniffers = append(r.StreamSniffers, sniff.TLSClientHello) + case C.ProtocolHTTP: + r.StreamSniffers = append(r.StreamSniffers, sniff.HTTPHost) + case C.ProtocolQUIC: + r.PacketSniffers = append(r.PacketSniffers, sniff.QUICClientHello) + case C.ProtocolDNS: + r.StreamSniffers = append(r.StreamSniffers, sniff.StreamDomainNameQuery) + r.PacketSniffers = append(r.PacketSniffers, sniff.DomainNameQuery) + case C.ProtocolSTUN: + r.PacketSniffers = append(r.PacketSniffers, sniff.STUNMessage) + case C.ProtocolBitTorrent: + r.StreamSniffers = append(r.StreamSniffers, sniff.BitTorrent) + r.PacketSniffers = append(r.PacketSniffers, sniff.UTP) + r.PacketSniffers = append(r.PacketSniffers, sniff.UDPTracker) + case C.ProtocolDTLS: + r.PacketSniffers = append(r.PacketSniffers, sniff.DTLSRecord) + case C.ProtocolSSH: + r.StreamSniffers = append(r.StreamSniffers, sniff.SSH) + case C.ProtocolRDP: + r.StreamSniffers = append(r.StreamSniffers, sniff.RDP) + case C.ProtocolNTP: + r.PacketSniffers = append(r.PacketSniffers, sniff.NTP) + default: + return E.New("unknown sniffer: ", name) + } + } + return nil +} + +func (r *RuleActionSniff) String() string { + if len(r.SnifferNames) == 0 && r.Timeout == 0 { + return "sniff" + } else if len(r.SnifferNames) > 0 && r.Timeout == 0 { + return F.ToString("sniff(", strings.Join(r.SnifferNames, ","), ")") + } else if len(r.SnifferNames) == 0 && r.Timeout > 0 { + return F.ToString("sniff(", r.Timeout.String(), ")") + } else { + return F.ToString("sniff(", strings.Join(r.SnifferNames, ","), ",", r.Timeout.String(), ")") + } +} + +type RuleActionResolve struct { + Server string + Strategy C.DomainStrategy + DisableCache bool + DisableOptimisticCache bool + RewriteTTL *uint32 + ClientSubnet netip.Prefix +} + +func (r *RuleActionResolve) Type() string { + return C.RuleActionTypeResolve +} + +func (r *RuleActionResolve) String() string { + var options []string + if r.Server != "" { + options = append(options, r.Server) + } + if r.Strategy != C.DomainStrategyAsIS { + options = append(options, F.ToString(option.DomainStrategy(r.Strategy))) + } + if r.DisableCache { + options = append(options, "disable_cache") + } + if r.DisableOptimisticCache { + options = append(options, "disable_optimistic_cache") + } + if r.RewriteTTL != nil { + options = append(options, F.ToString("rewrite_ttl=", *r.RewriteTTL)) + } + if r.ClientSubnet.IsValid() { + options = append(options, F.ToString("client_subnet=", r.ClientSubnet)) + } + if len(options) == 0 { + return "resolve" + } else { + return F.ToString("resolve(", strings.Join(options, ","), ")") + } +} + +type RuleActionPredefined struct { + Rcode int + Answer []dns.RR + Ns []dns.RR + Extra []dns.RR +} + +func (r *RuleActionPredefined) Type() string { + return C.RuleActionTypePredefined +} + +func (r *RuleActionPredefined) String() string { + var options []string + options = append(options, dns.RcodeToString[r.Rcode]) + options = append(options, common.Map(r.Answer, dns.RR.String)...) + options = append(options, common.Map(r.Ns, dns.RR.String)...) + options = append(options, common.Map(r.Extra, dns.RR.String)...) + return F.ToString("predefined(", strings.Join(options, ","), ")") +} + +func (r *RuleActionPredefined) Response(request *dns.Msg) *dns.Msg { + return &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: request.Id, + Response: true, + Authoritative: true, + RecursionDesired: true, + RecursionAvailable: true, + Rcode: r.Rcode, + }, + Question: request.Question, + Answer: rewriteRecords(r.Answer, request.Question[0]), + Ns: rewriteRecords(r.Ns, request.Question[0]), + Extra: rewriteRecords(r.Extra, request.Question[0]), + } +} + +func rewriteRecords(records []dns.RR, question dns.Question) []dns.RR { + return common.Map(records, func(it dns.RR) dns.RR { + if strings.HasPrefix(it.Header().Name, "*") { + if strings.HasSuffix(question.Name, it.Header().Name[1:]) { + it = dns.Copy(it) + it.Header().Name = question.Name + } + } + return it + }) +} diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go new file mode 100644 index 00000000..774e1b7c --- /dev/null +++ b/route/rule/rule_default.go @@ -0,0 +1,348 @@ +package rule + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func NewRule(ctx context.Context, logger log.ContextLogger, options option.Rule, checkOutbound bool) (adapter.Rule, error) { + switch options.Type { + case "", C.RuleTypeDefault: + if !options.DefaultOptions.IsValid() { + return nil, E.New("missing conditions") + } + switch options.DefaultOptions.Action { + case "", C.RuleActionTypeRoute: + if options.DefaultOptions.RouteOptions.Outbound == "" && checkOutbound { + return nil, E.New("missing outbound field") + } + } + return NewDefaultRule(ctx, logger, options.DefaultOptions) + case C.RuleTypeLogical: + if !options.LogicalOptions.IsValid() { + return nil, E.New("missing conditions") + } + switch options.LogicalOptions.Action { + case "", C.RuleActionTypeRoute: + if options.LogicalOptions.RouteOptions.Outbound == "" && checkOutbound { + return nil, E.New("missing outbound field") + } + } + return NewLogicalRule(ctx, logger, options.LogicalOptions) + default: + return nil, E.New("unknown rule type: ", options.Type) + } +} + +var _ adapter.Rule = (*DefaultRule)(nil) + +type DefaultRule struct { + abstractDefaultRule +} + +func (r *DefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractDefaultRule.matchStates(metadata) +} + +type RuleItem interface { + Match(metadata *adapter.InboundContext) bool + String() string +} + +func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options option.DefaultRule) (*DefaultRule, error) { + action, err := NewRuleAction(ctx, logger, options.RuleAction) + if err != nil { + return nil, E.Cause(err, "action") + } + rule := &DefaultRule{ + abstractDefaultRule{ + invert: options.Invert, + action: action, + }, + } + router := service.FromContext[adapter.Router](ctx) + networkManager := service.FromContext[adapter.NetworkManager](ctx) + if len(options.Inbound) > 0 { + item := NewInboundRule(options.Inbound) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.IPVersion > 0 { + switch options.IPVersion { + case 4, 6: + item := NewIPVersionItem(options.IPVersion == 6) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + default: + return nil, E.New("invalid ip version: ", options.IPVersion) + } + } + if len(options.Network) > 0 { + item := NewNetworkItem(options.Network) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.AuthUser) > 0 { + item := NewAuthUserItem(options.AuthUser) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Protocol) > 0 { + item := NewProtocolItem(options.Protocol) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Client) > 0 { + item := NewClientItem(options.Client) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { + item, err := NewDomainItem(options.Domain, options.DomainSuffix) + if err != nil { + return nil, err + } + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DomainKeyword) > 0 { + item := NewDomainKeywordItem(options.DomainKeyword) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DomainRegex) > 0 { + item, err := NewDomainRegexItem(options.DomainRegex) + if err != nil { + return nil, err + } + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Geosite) > 0 { + return nil, E.New("geosite database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") + } + if len(options.SourceGeoIP) > 0 { + return nil, E.New("geoip database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") + } + if len(options.GeoIP) > 0 { + return nil, E.New("geoip database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") + } + if len(options.SourceIPCIDR) > 0 { + item, err := NewIPCIDRItem(true, options.SourceIPCIDR) + if err != nil { + return nil, E.Cause(err, "source_ip_cidr") + } + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if options.SourceIPIsPrivate { + item := NewIPIsPrivateItem(true) + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.IPCIDR) > 0 { + item, err := NewIPCIDRItem(false, options.IPCIDR) + if err != nil { + return nil, E.Cause(err, "ipcidr") + } + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } + if options.IPIsPrivate { + item := NewIPIsPrivateItem(false) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourcePort) > 0 { + item := NewPortItem(true, options.SourcePort) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourcePortRange) > 0 { + item, err := NewPortRangeItem(true, options.SourcePortRange) + if err != nil { + return nil, E.Cause(err, "source_port_range") + } + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Port) > 0 { + item := NewPortItem(false, options.Port) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PortRange) > 0 { + item, err := NewPortRangeItem(false, options.PortRange) + if err != nil { + return nil, E.Cause(err, "port_range") + } + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessName) > 0 { + item := NewProcessItem(options.ProcessName) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessPath) > 0 { + item := NewProcessPathItem(options.ProcessPath) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessPathRegex) > 0 { + item, err := NewProcessPathRegexItem(options.ProcessPathRegex) + if err != nil { + return nil, E.Cause(err, "process_path_regex") + } + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PackageName) > 0 { + item := NewPackageNameItem(options.PackageName) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PackageNameRegex) > 0 { + item, err := NewPackageNameRegexItem(options.PackageNameRegex) + if err != nil { + return nil, E.Cause(err, "package_name_regex") + } + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.User) > 0 { + item := NewUserItem(options.User) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.UserID) > 0 { + item := NewUserIDItem(options.UserID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.ClashMode != "" { + item := NewClashModeItem(ctx, options.ClashMode) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.NetworkType) > 0 { + item := NewNetworkTypeItem(networkManager, common.Map(options.NetworkType, option.InterfaceType.Build)) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkIsExpensive { + item := NewNetworkIsExpensiveItem(networkManager) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkIsConstrained { + item := NewNetworkIsConstrainedItem(networkManager) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.WIFISSID) > 0 { + item := NewWIFISSIDItem(networkManager, options.WIFISSID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.WIFIBSSID) > 0 { + item := NewWIFIBSSIDItem(networkManager, options.WIFIBSSID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.InterfaceAddress != nil && options.InterfaceAddress.Size() > 0 { + item := NewInterfaceAddressItem(networkManager, options.InterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkInterfaceAddress != nil && options.NetworkInterfaceAddress.Size() > 0 { + item := NewNetworkInterfaceAddressItem(networkManager, options.NetworkInterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DefaultInterfaceAddress) > 0 { + item := NewDefaultInterfaceAddressItem(networkManager, options.DefaultInterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceMACAddress) > 0 { + item := NewSourceMACAddressItem(options.SourceMACAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceHostname) > 0 { + item := NewSourceHostnameItem(options.SourceHostname) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PreferredBy) > 0 { + item := NewPreferredByItem(ctx, options.PreferredBy) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.RuleSet) > 0 { + //nolint:staticcheck + if options.Deprecated_RulesetIPCIDRMatchSource { + return nil, E.New("rule_set_ipcidr_match_source is deprecated in sing-box 1.10.0 and removed in sing-box 1.11.0") + } + var matchSource bool + if options.RuleSetIPCIDRMatchSource { + matchSource = true + } + item := NewRuleSetItem(router, options.RuleSet, matchSource, false) + rule.ruleSetItem = item + rule.allItems = append(rule.allItems, item) + } + return rule, nil +} + +var _ adapter.Rule = (*LogicalRule)(nil) + +type LogicalRule struct { + abstractLogicalRule +} + +func (r *LogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractLogicalRule.matchStates(metadata) +} + +func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) { + action, err := NewRuleAction(ctx, logger, options.RuleAction) + if err != nil { + return nil, E.Cause(err, "action") + } + rule := &LogicalRule{ + abstractLogicalRule{ + rules: make([]adapter.HeadlessRule, len(options.Rules)), + invert: options.Invert, + action: action, + }, + } + switch options.Mode { + case C.LogicalTypeAnd: + rule.mode = C.LogicalTypeAnd + case C.LogicalTypeOr: + rule.mode = C.LogicalTypeOr + default: + return nil, E.New("unknown logical mode: ", options.Mode) + } + for i, subOptions := range options.Rules { + err = validateNoNestedRuleActions(subOptions, true) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } + subRule, err := NewRule(ctx, logger, subOptions, false) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } + rule.rules[i] = subRule + } + return rule, nil +} diff --git a/route/rule/rule_default_interface_address.go b/route/rule/rule_default_interface_address.go new file mode 100644 index 00000000..2d7fdebe --- /dev/null +++ b/route/rule/rule_default_interface_address.go @@ -0,0 +1,56 @@ +package rule + +import ( + "net/netip" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +var _ RuleItem = (*DefaultInterfaceAddressItem)(nil) + +type DefaultInterfaceAddressItem struct { + interfaceMonitor tun.DefaultInterfaceMonitor + interfaceAddresses []netip.Prefix +} + +func NewDefaultInterfaceAddressItem(networkManager adapter.NetworkManager, interfaceAddresses badoption.Listable[*badoption.Prefixable]) *DefaultInterfaceAddressItem { + item := &DefaultInterfaceAddressItem{ + interfaceMonitor: networkManager.InterfaceMonitor(), + interfaceAddresses: make([]netip.Prefix, 0, len(interfaceAddresses)), + } + for _, prefixable := range interfaceAddresses { + item.interfaceAddresses = append(item.interfaceAddresses, prefixable.Build(netip.Prefix{})) + } + return item +} + +func (r *DefaultInterfaceAddressItem) Match(metadata *adapter.InboundContext) bool { + defaultInterface := r.interfaceMonitor.DefaultInterface() + if defaultInterface == nil { + return false + } + for _, address := range r.interfaceAddresses { + if common.All(defaultInterface.Addresses, func(it netip.Prefix) bool { + return !address.Overlaps(it) + }) { + return false + } + } + return true +} + +func (r *DefaultInterfaceAddressItem) String() string { + addressLen := len(r.interfaceAddresses) + switch { + case addressLen == 1: + return "default_interface_address=" + r.interfaceAddresses[0].String() + case addressLen > 3: + return "default_interface_address=[" + strings.Join(common.Map(r.interfaceAddresses[:3], netip.Prefix.String), " ") + "...]" + default: + return "default_interface_address=[" + strings.Join(common.Map(r.interfaceAddresses, netip.Prefix.String), " ") + "]" + } +} diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go new file mode 100644 index 00000000..646f987e --- /dev/null +++ b/route/rule/rule_dns.go @@ -0,0 +1,525 @@ +package rule + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + + "github.com/miekg/dns" +) + +func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DNSRule, checkServer bool, legacyDNSMode bool) (adapter.DNSRule, error) { + switch options.Type { + case "", C.RuleTypeDefault: + if !options.DefaultOptions.IsValid() { + return nil, E.New("missing conditions") + } + if !checkServer && options.DefaultOptions.Action == C.RuleActionTypeEvaluate { + return nil, E.New(options.DefaultOptions.Action, " is only allowed on top-level DNS rules") + } + err := validateDNSRuleAction(options.DefaultOptions.DNSRuleAction) + if err != nil { + return nil, err + } + switch options.DefaultOptions.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + if options.DefaultOptions.RouteOptions.Server == "" && checkServer { + return nil, E.New("missing server field") + } + } + return NewDefaultDNSRule(ctx, logger, options.DefaultOptions, legacyDNSMode) + case C.RuleTypeLogical: + if !options.LogicalOptions.IsValid() { + return nil, E.New("missing conditions") + } + if !checkServer && options.LogicalOptions.Action == C.RuleActionTypeEvaluate { + return nil, E.New(options.LogicalOptions.Action, " is only allowed on top-level DNS rules") + } + err := validateDNSRuleAction(options.LogicalOptions.DNSRuleAction) + if err != nil { + return nil, err + } + switch options.LogicalOptions.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + if options.LogicalOptions.RouteOptions.Server == "" && checkServer { + return nil, E.New("missing server field") + } + } + return NewLogicalDNSRule(ctx, logger, options.LogicalOptions, legacyDNSMode) + default: + return nil, E.New("unknown rule type: ", options.Type) + } +} + +func validateDNSRuleAction(action option.DNSRuleAction) error { + if action.Action == C.RuleActionTypeReject && action.RejectOptions.Method == C.RuleActionRejectMethodReply { + return E.New("reject method `reply` is not supported for DNS rules") + } + return nil +} + +var _ adapter.DNSRule = (*DefaultDNSRule)(nil) + +type DefaultDNSRule struct { + abstractDefaultRule + matchResponse bool +} + +func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractDefaultRule.matchStates(metadata) +} + +func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { + rule := &DefaultDNSRule{ + abstractDefaultRule: abstractDefaultRule{ + invert: options.Invert, + action: NewDNSRuleAction(logger, options.DNSRuleAction), + }, + matchResponse: options.MatchResponse, + } + if len(options.Inbound) > 0 { + item := NewInboundRule(options.Inbound) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + router := service.FromContext[adapter.Router](ctx) + networkManager := service.FromContext[adapter.NetworkManager](ctx) + if options.IPVersion > 0 { + switch options.IPVersion { + case 4, 6: + item := NewIPVersionItem(options.IPVersion == 6) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + default: + return nil, E.New("invalid ip version: ", options.IPVersion) + } + } + if len(options.QueryType) > 0 { + item := NewQueryTypeItem(options.QueryType) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Network) > 0 { + item := NewNetworkItem(options.Network) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.AuthUser) > 0 { + item := NewAuthUserItem(options.AuthUser) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Protocol) > 0 { + item := NewProtocolItem(options.Protocol) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { + item, err := NewDomainItem(options.Domain, options.DomainSuffix) + if err != nil { + return nil, err + } + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DomainKeyword) > 0 { + item := NewDomainKeywordItem(options.DomainKeyword) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DomainRegex) > 0 { + item, err := NewDomainRegexItem(options.DomainRegex) + if err != nil { + return nil, E.Cause(err, "domain_regex") + } + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Geosite) > 0 { //nolint:staticcheck + return nil, E.New("geosite database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") + } + if len(options.SourceGeoIP) > 0 { + return nil, E.New("geoip database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") + } + if len(options.GeoIP) > 0 { + return nil, E.New("geoip database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") + } + if len(options.SourceIPCIDR) > 0 { + item, err := NewIPCIDRItem(true, options.SourceIPCIDR) + if err != nil { + return nil, E.Cause(err, "source_ip_cidr") + } + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.IPCIDR) > 0 { + item, err := NewIPCIDRItem(false, options.IPCIDR) + if err != nil { + return nil, E.Cause(err, "ip_cidr") + } + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } + if options.SourceIPIsPrivate { + item := NewIPIsPrivateItem(true) + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if options.IPIsPrivate { + item := NewIPIsPrivateItem(false) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } + if options.IPAcceptAny { + item := NewIPAcceptAnyItem() + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } + if options.ResponseRcode != nil { + item := NewDNSResponseRCodeItem(int(*options.ResponseRcode)) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ResponseAnswer) > 0 { + item := NewDNSResponseRecordItem("response_answer", options.ResponseAnswer, dnsResponseAnswers) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ResponseNs) > 0 { + item := NewDNSResponseRecordItem("response_ns", options.ResponseNs, dnsResponseNS) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ResponseExtra) > 0 { + item := NewDNSResponseRecordItem("response_extra", options.ResponseExtra, dnsResponseExtra) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourcePort) > 0 { + item := NewPortItem(true, options.SourcePort) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourcePortRange) > 0 { + item, err := NewPortRangeItem(true, options.SourcePortRange) + if err != nil { + return nil, E.Cause(err, "source_port_range") + } + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Port) > 0 { + item := NewPortItem(false, options.Port) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PortRange) > 0 { + item, err := NewPortRangeItem(false, options.PortRange) + if err != nil { + return nil, E.Cause(err, "port_range") + } + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessName) > 0 { + item := NewProcessItem(options.ProcessName) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessPath) > 0 { + item := NewProcessPathItem(options.ProcessPath) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessPathRegex) > 0 { + item, err := NewProcessPathRegexItem(options.ProcessPathRegex) + if err != nil { + return nil, E.Cause(err, "process_path_regex") + } + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PackageName) > 0 { + item := NewPackageNameItem(options.PackageName) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PackageNameRegex) > 0 { + item, err := NewPackageNameRegexItem(options.PackageNameRegex) + if err != nil { + return nil, E.Cause(err, "package_name_regex") + } + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.User) > 0 { + item := NewUserItem(options.User) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.UserID) > 0 { + item := NewUserIDItem(options.UserID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Outbound) > 0 { + item := NewOutboundRule(ctx, options.Outbound) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.ClashMode != "" { + item := NewClashModeItem(ctx, options.ClashMode) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.NetworkType) > 0 { + item := NewNetworkTypeItem(networkManager, common.Map(options.NetworkType, option.InterfaceType.Build)) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkIsExpensive { + item := NewNetworkIsExpensiveItem(networkManager) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkIsConstrained { + item := NewNetworkIsConstrainedItem(networkManager) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.WIFISSID) > 0 { + item := NewWIFISSIDItem(networkManager, options.WIFISSID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.WIFIBSSID) > 0 { + item := NewWIFIBSSIDItem(networkManager, options.WIFIBSSID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.InterfaceAddress != nil && options.InterfaceAddress.Size() > 0 { + item := NewInterfaceAddressItem(networkManager, options.InterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkInterfaceAddress != nil && options.NetworkInterfaceAddress.Size() > 0 { + item := NewNetworkInterfaceAddressItem(networkManager, options.NetworkInterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DefaultInterfaceAddress) > 0 { + item := NewDefaultInterfaceAddressItem(networkManager, options.DefaultInterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceMACAddress) > 0 { + item := NewSourceMACAddressItem(options.SourceMACAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceHostname) > 0 { + item := NewSourceHostnameItem(options.SourceHostname) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck + if legacyDNSMode { + deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) + } else { + return nil, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + } + } + if len(options.RuleSet) > 0 { + //nolint:staticcheck + if options.Deprecated_RulesetIPCIDRMatchSource { + return nil, E.New("rule_set_ipcidr_match_source is deprecated in sing-box 1.10.0 and removed in sing-box 1.11.0") + } + var matchSource bool + if options.RuleSetIPCIDRMatchSource { + matchSource = true + } + item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) //nolint:staticcheck + rule.ruleSetItem = item + rule.allItems = append(rule.allItems, item) + } + return rule, nil +} + +func (r *DefaultDNSRule) Action() adapter.RuleAction { + return r.action +} + +func (r *DefaultDNSRule) WithAddressLimit() bool { + if len(r.destinationIPCIDRItems) > 0 { + return true + } + if r.ruleSetItem != nil { + ruleSet, isRuleSet := r.ruleSetItem.(*RuleSetItem) + if isRuleSet && ruleSet.ContainsDestinationIPCIDRRule() { + return true + } + } + return false +} + +func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { + return !r.matchStatesForMatch(metadata).isEmpty() +} + +func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { + if r.matchResponse { + return false + } + metadata.IgnoreDestinationIPCIDRMatch = true + defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() + return !r.abstractDefaultRule.matchStates(metadata).isEmpty() +} + +func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + if r.matchResponse { + if metadata.DNSResponse == nil { + return r.abstractDefaultRule.invertedFailure(0) + } + matchMetadata := *metadata + matchMetadata.DestinationAddressMatchFromResponse = true + return r.abstractDefaultRule.matchStates(&matchMetadata) + } + return r.abstractDefaultRule.matchStates(metadata) +} + +func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { + matchMetadata := *metadata + matchMetadata.DNSResponse = response + matchMetadata.DestinationAddressMatchFromResponse = true + return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() +} + +var _ adapter.DNSRule = (*LogicalDNSRule)(nil) + +type LogicalDNSRule struct { + abstractLogicalRule +} + +func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractLogicalRule.matchStates(metadata) +} + +func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { + switch typedRule := rule.(type) { + case *DefaultDNSRule: + return typedRule.matchStatesForMatch(metadata) + case *LogicalDNSRule: + return typedRule.matchStatesForMatch(metadata) + default: + return matchHeadlessRuleStates(typedRule, metadata) + } +} + +func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + var stateSet ruleMatchStateSet + if r.mode == C.LogicalTypeAnd { + stateSet = emptyRuleMatchState() + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) + if nestedStateSet.isEmpty() { + if r.invert { + return emptyRuleMatchState() + } + return 0 + } + stateSet = stateSet.combine(nestedStateSet) + } + } else { + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) + } + if stateSet.isEmpty() { + if r.invert { + return emptyRuleMatchState() + } + return 0 + } + } + if r.invert { + return 0 + } + return stateSet +} + +func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyDNSMode bool) (*LogicalDNSRule, error) { + r := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: make([]adapter.HeadlessRule, len(options.Rules)), + invert: options.Invert, + action: NewDNSRuleAction(logger, options.DNSRuleAction), + }, + } + switch options.Mode { + case C.LogicalTypeAnd: + r.mode = C.LogicalTypeAnd + case C.LogicalTypeOr: + r.mode = C.LogicalTypeOr + default: + return nil, E.New("unknown logical mode: ", options.Mode) + } + for i, subRule := range options.Rules { + err := validateNoNestedDNSRuleActions(subRule, true) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } + rule, err := NewDNSRule(ctx, logger, subRule, false, legacyDNSMode) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } + r.rules[i] = rule + } + return r, nil +} + +func (r *LogicalDNSRule) Action() adapter.RuleAction { + return r.action +} + +func (r *LogicalDNSRule) WithAddressLimit() bool { + for _, rawRule := range r.rules { + switch rule := rawRule.(type) { + case *DefaultDNSRule: + if rule.WithAddressLimit() { + return true + } + case *LogicalDNSRule: + if rule.WithAddressLimit() { + return true + } + } + } + return false +} + +func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { + return !r.matchStatesForMatch(metadata).isEmpty() +} + +func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { + metadata.IgnoreDestinationIPCIDRMatch = true + defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() + return !r.abstractLogicalRule.matchStates(metadata).isEmpty() +} + +func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { + matchMetadata := *metadata + matchMetadata.DNSResponse = response + matchMetadata.DestinationAddressMatchFromResponse = true + return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() +} diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go new file mode 100644 index 00000000..ab85e0d5 --- /dev/null +++ b/route/rule/rule_headless.go @@ -0,0 +1,246 @@ +package rule + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func NewHeadlessRule(ctx context.Context, options option.HeadlessRule) (adapter.HeadlessRule, error) { + switch options.Type { + case "", C.RuleTypeDefault: + if !options.DefaultOptions.IsValid() { + return nil, E.New("missing conditions") + } + return NewDefaultHeadlessRule(ctx, options.DefaultOptions) + case C.RuleTypeLogical: + if !options.LogicalOptions.IsValid() { + return nil, E.New("missing conditions") + } + return NewLogicalHeadlessRule(ctx, options.LogicalOptions) + default: + return nil, E.New("unknown rule type: ", options.Type) + } +} + +var _ adapter.HeadlessRule = (*DefaultHeadlessRule)(nil) + +type DefaultHeadlessRule struct { + abstractDefaultRule +} + +func (r *DefaultHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractDefaultRule.matchStates(metadata) +} + +func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessRule) (*DefaultHeadlessRule, error) { + networkManager := service.FromContext[adapter.NetworkManager](ctx) + rule := &DefaultHeadlessRule{ + abstractDefaultRule{ + invert: options.Invert, + }, + } + if len(options.QueryType) > 0 { + item := NewQueryTypeItem(options.QueryType) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Network) > 0 { + item := NewNetworkItem(options.Network) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { + item, err := NewDomainItem(options.Domain, options.DomainSuffix) + if err != nil { + return nil, err + } + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } else if options.DomainMatcher != nil { + item := NewRawDomainItem(options.DomainMatcher) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DomainKeyword) > 0 { + item := NewDomainKeywordItem(options.DomainKeyword) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DomainRegex) > 0 { + item, err := NewDomainRegexItem(options.DomainRegex) + if err != nil { + return nil, E.Cause(err, "domain_regex") + } + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceIPCIDR) > 0 { + item, err := NewIPCIDRItem(true, options.SourceIPCIDR) + if err != nil { + return nil, E.Cause(err, "source_ip_cidr") + } + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) + } else if options.SourceIPSet != nil { + item := NewRawIPCIDRItem(true, options.SourceIPSet) + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.IPCIDR) > 0 { + item, err := NewIPCIDRItem(false, options.IPCIDR) + if err != nil { + return nil, E.Cause(err, "ipcidr") + } + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } else if options.IPSet != nil { + item := NewRawIPCIDRItem(false, options.IPSet) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourcePort) > 0 { + item := NewPortItem(true, options.SourcePort) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourcePortRange) > 0 { + item, err := NewPortRangeItem(true, options.SourcePortRange) + if err != nil { + return nil, E.Cause(err, "source_port_range") + } + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.Port) > 0 { + item := NewPortItem(false, options.Port) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PortRange) > 0 { + item, err := NewPortRangeItem(false, options.PortRange) + if err != nil { + return nil, E.Cause(err, "port_range") + } + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessName) > 0 { + item := NewProcessItem(options.ProcessName) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessPath) > 0 { + item := NewProcessPathItem(options.ProcessPath) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ProcessPathRegex) > 0 { + item, err := NewProcessPathRegexItem(options.ProcessPathRegex) + if err != nil { + return nil, E.Cause(err, "process_path_regex") + } + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PackageName) > 0 { + item := NewPackageNameItem(options.PackageName) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.PackageNameRegex) > 0 { + item, err := NewPackageNameRegexItem(options.PackageNameRegex) + if err != nil { + return nil, E.Cause(err, "package_name_regex") + } + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if networkManager != nil { + if len(options.NetworkType) > 0 { + item := NewNetworkTypeItem(networkManager, common.Map(options.NetworkType, option.InterfaceType.Build)) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkIsExpensive { + item := NewNetworkIsExpensiveItem(networkManager) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkIsConstrained { + item := NewNetworkIsConstrainedItem(networkManager) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.WIFISSID) > 0 { + item := NewWIFISSIDItem(networkManager, options.WIFISSID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.WIFIBSSID) > 0 { + item := NewWIFIBSSIDItem(networkManager, options.WIFIBSSID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if options.NetworkInterfaceAddress != nil && options.NetworkInterfaceAddress.Size() > 0 { + item := NewNetworkInterfaceAddressItem(networkManager, options.NetworkInterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.DefaultInterfaceAddress) > 0 { + item := NewDefaultInterfaceAddressItem(networkManager, options.DefaultInterfaceAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + } + if len(options.AdGuardDomain) > 0 { + item := NewAdGuardDomainItem(options.AdGuardDomain) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } else if options.AdGuardDomainMatcher != nil { + item := NewRawAdGuardDomainItem(options.AdGuardDomainMatcher) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) + } + return rule, nil +} + +var _ adapter.HeadlessRule = (*LogicalHeadlessRule)(nil) + +type LogicalHeadlessRule struct { + abstractLogicalRule +} + +func (r *LogicalHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractLogicalRule.matchStates(metadata) +} + +func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) { + r := &LogicalHeadlessRule{ + abstractLogicalRule{ + rules: make([]adapter.HeadlessRule, len(options.Rules)), + invert: options.Invert, + }, + } + switch options.Mode { + case C.LogicalTypeAnd: + r.mode = C.LogicalTypeAnd + case C.LogicalTypeOr: + r.mode = C.LogicalTypeOr + default: + return nil, E.New("unknown logical mode: ", options.Mode) + } + for i, subRule := range options.Rules { + rule, err := NewHeadlessRule(ctx, subRule) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } + r.rules[i] = rule + } + return r, nil +} diff --git a/route/rule/rule_interface_address.go b/route/rule/rule_interface_address.go new file mode 100644 index 00000000..d4c75d38 --- /dev/null +++ b/route/rule/rule_interface_address.go @@ -0,0 +1,62 @@ +package rule + +import ( + "net/netip" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +var _ RuleItem = (*InterfaceAddressItem)(nil) + +type InterfaceAddressItem struct { + networkManager adapter.NetworkManager + interfaceAddresses map[string][]netip.Prefix + description string +} + +func NewInterfaceAddressItem(networkManager adapter.NetworkManager, interfaceAddresses *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]]) *InterfaceAddressItem { + item := &InterfaceAddressItem{ + networkManager: networkManager, + interfaceAddresses: make(map[string][]netip.Prefix, interfaceAddresses.Size()), + } + var entryDescriptions []string + for _, entry := range interfaceAddresses.Entries() { + prefixes := make([]netip.Prefix, 0, len(entry.Value)) + for _, prefixable := range entry.Value { + prefixes = append(prefixes, prefixable.Build(netip.Prefix{})) + } + item.interfaceAddresses[entry.Key] = prefixes + entryDescriptions = append(entryDescriptions, entry.Key+"="+strings.Join(common.Map(prefixes, netip.Prefix.String), ",")) + } + item.description = "interface_address=[" + strings.Join(entryDescriptions, " ") + "]" + return item +} + +func (r *InterfaceAddressItem) Match(metadata *adapter.InboundContext) bool { + interfaces := r.networkManager.InterfaceFinder().Interfaces() + for ifName, addresses := range r.interfaceAddresses { + iface := common.Find(interfaces, func(it control.Interface) bool { + return it.Name == ifName + }) + if iface.Name == "" { + return false + } + if common.All(addresses, func(address netip.Prefix) bool { + return common.All(iface.Addresses, func(it netip.Prefix) bool { + return !address.Overlaps(it) + }) + }) { + return false + } + } + return true +} + +func (r *InterfaceAddressItem) String() string { + return r.description +} diff --git a/route/rule/rule_item_adguard.go b/route/rule/rule_item_adguard.go new file mode 100644 index 00000000..84252e60 --- /dev/null +++ b/route/rule/rule_item_adguard.go @@ -0,0 +1,43 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/domain" +) + +var _ RuleItem = (*AdGuardDomainItem)(nil) + +type AdGuardDomainItem struct { + matcher *domain.AdGuardMatcher +} + +func NewAdGuardDomainItem(ruleLines []string) *AdGuardDomainItem { + return &AdGuardDomainItem{ + domain.NewAdGuardMatcher(ruleLines), + } +} + +func NewRawAdGuardDomainItem(matcher *domain.AdGuardMatcher) *AdGuardDomainItem { + return &AdGuardDomainItem{ + matcher, + } +} + +func (r *AdGuardDomainItem) Match(metadata *adapter.InboundContext) bool { + var domainHost string + if metadata.Domain != "" { + domainHost = metadata.Domain + } else { + domainHost = metadata.Destination.Fqdn + } + if domainHost == "" { + return false + } + return r.matcher.Match(strings.ToLower(domainHost)) +} + +func (r *AdGuardDomainItem) String() string { + return "!adguard_domain_rules=" +} diff --git a/route/rule/rule_item_auth_user.go b/route/rule/rule_item_auth_user.go new file mode 100644 index 00000000..5799e3c7 --- /dev/null +++ b/route/rule/rule_item_auth_user.go @@ -0,0 +1,37 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*AuthUserItem)(nil) + +type AuthUserItem struct { + users []string + userMap map[string]bool +} + +func NewAuthUserItem(users []string) *AuthUserItem { + userMap := make(map[string]bool) + for _, protocol := range users { + userMap[protocol] = true + } + return &AuthUserItem{ + users: users, + userMap: userMap, + } +} + +func (r *AuthUserItem) Match(metadata *adapter.InboundContext) bool { + return r.userMap[metadata.User] +} + +func (r *AuthUserItem) String() string { + if len(r.users) == 1 { + return F.ToString("auth_user=", r.users[0]) + } + return F.ToString("auth_user=[", strings.Join(r.users, " "), "]") +} diff --git a/route/rule/rule_item_cidr.go b/route/rule/rule_item_cidr.go new file mode 100644 index 00000000..28f74161 --- /dev/null +++ b/route/rule/rule_item_cidr.go @@ -0,0 +1,110 @@ +package rule + +import ( + "net/netip" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "go4.org/netipx" +) + +var _ RuleItem = (*IPCIDRItem)(nil) + +type IPCIDRItem struct { + ipSet *netipx.IPSet + isSource bool + description string +} + +func NewIPCIDRItem(isSource bool, prefixStrings []string) (*IPCIDRItem, error) { + var builder netipx.IPSetBuilder + for i, prefixString := range prefixStrings { + prefix, err := netip.ParsePrefix(prefixString) + if err == nil { + builder.AddPrefix(prefix) + continue + } + addr, addrErr := netip.ParseAddr(prefixString) + if addrErr == nil { + builder.Add(addr) + continue + } + return nil, E.Cause(err, "parse [", i, "]") + } + var description string + if isSource { + description = "source_ip_cidr=" + } else { + description = "ip_cidr=" + } + if dLen := len(prefixStrings); dLen == 1 { + description += prefixStrings[0] + } else if dLen > 3 { + description += "[" + strings.Join(prefixStrings[:3], " ") + "...]" + } else { + description += "[" + strings.Join(prefixStrings, " ") + "]" + } + ipSet, err := builder.IPSet() + if err != nil { + return nil, err + } + return &IPCIDRItem{ + ipSet: ipSet, + isSource: isSource, + description: description, + }, nil +} + +func NewRawIPCIDRItem(isSource bool, ipSet *netipx.IPSet) *IPCIDRItem { + var description string + if isSource { + description = "source_ip_cidr=" + } else { + description = "ip_cidr=" + } + description += "" + return &IPCIDRItem{ + ipSet: ipSet, + isSource: isSource, + description: description, + } +} + +func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { + if r.isSource || metadata.IPCIDRMatchSource { + return r.ipSet.Contains(metadata.Source.Addr) + } + if metadata.DestinationAddressMatchFromResponse { + addresses := metadata.DNSResponseAddressesForMatch() + if len(addresses) == 0 { + // Legacy rule_set_ip_cidr_accept_empty only applies when the DNS response + // does not expose any address answers for matching. + return metadata.IPCIDRAcceptEmpty + } + for _, address := range addresses { + if r.ipSet.Contains(address) { + return true + } + } + return false + } + if metadata.Destination.IsIP() { + return r.ipSet.Contains(metadata.Destination.Addr) + } + addresses := metadata.DestinationAddresses + if len(addresses) > 0 { + for _, address := range addresses { + if r.ipSet.Contains(address) { + return true + } + } + return false + } + return metadata.IPCIDRAcceptEmpty +} + +func (r *IPCIDRItem) String() string { + return r.description +} diff --git a/route/rule/rule_item_clash_mode.go b/route/rule/rule_item_clash_mode.go new file mode 100644 index 00000000..fe2347a0 --- /dev/null +++ b/route/rule/rule_item_clash_mode.go @@ -0,0 +1,40 @@ +package rule + +import ( + "context" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/service" +) + +var _ RuleItem = (*ClashModeItem)(nil) + +type ClashModeItem struct { + ctx context.Context + clashServer adapter.ClashServer + mode string +} + +func NewClashModeItem(ctx context.Context, mode string) *ClashModeItem { + return &ClashModeItem{ + ctx: ctx, + mode: mode, + } +} + +func (r *ClashModeItem) Start() error { + r.clashServer = service.FromContext[adapter.ClashServer](r.ctx) + return nil +} + +func (r *ClashModeItem) Match(metadata *adapter.InboundContext) bool { + if r.clashServer == nil { + return false + } + return strings.EqualFold(r.clashServer.Mode(), r.mode) +} + +func (r *ClashModeItem) String() string { + return "clash_mode=" + r.mode +} diff --git a/route/rule/rule_item_client.go b/route/rule/rule_item_client.go new file mode 100644 index 00000000..63ff4103 --- /dev/null +++ b/route/rule/rule_item_client.go @@ -0,0 +1,37 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*ClientItem)(nil) + +type ClientItem struct { + clients []string + clientMap map[string]bool +} + +func NewClientItem(clients []string) *ClientItem { + clientMap := make(map[string]bool) + for _, client := range clients { + clientMap[client] = true + } + return &ClientItem{ + clients: clients, + clientMap: clientMap, + } +} + +func (r *ClientItem) Match(metadata *adapter.InboundContext) bool { + return r.clientMap[metadata.Client] +} + +func (r *ClientItem) String() string { + if len(r.clients) == 1 { + return F.ToString("client=", r.clients[0]) + } + return F.ToString("client=[", strings.Join(r.clients, " "), "]") +} diff --git a/route/rule/rule_item_domain.go b/route/rule/rule_item_domain.go new file mode 100644 index 00000000..af790aa3 --- /dev/null +++ b/route/rule/rule_item_domain.go @@ -0,0 +1,79 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/domain" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ RuleItem = (*DomainItem)(nil) + +type DomainItem struct { + matcher *domain.Matcher + description string +} + +func NewDomainItem(domains []string, domainSuffixes []string) (*DomainItem, error) { + for _, domainItem := range domains { + if domainItem == "" { + return nil, E.New("domain: empty item is not allowed") + } + } + for _, domainSuffixItem := range domainSuffixes { + if domainSuffixItem == "" { + return nil, E.New("domain_suffix: empty item is not allowed") + } + } + var description string + if dLen := len(domains); dLen > 0 { + if dLen == 1 { + description = "domain=" + domains[0] + } else if dLen > 3 { + description = "domain=[" + strings.Join(domains[:3], " ") + "...]" + } else { + description = "domain=[" + strings.Join(domains, " ") + "]" + } + } + if dsLen := len(domainSuffixes); dsLen > 0 { + if len(description) > 0 { + description += " " + } + if dsLen == 1 { + description += "domain_suffix=" + domainSuffixes[0] + } else if dsLen > 3 { + description += "domain_suffix=[" + strings.Join(domainSuffixes[:3], " ") + "...]" + } else { + description += "domain_suffix=[" + strings.Join(domainSuffixes, " ") + "]" + } + } + return &DomainItem{ + domain.NewMatcher(domains, domainSuffixes, false), + description, + }, nil +} + +func NewRawDomainItem(matcher *domain.Matcher) *DomainItem { + return &DomainItem{ + matcher, + "domain/domain_suffix=", + } +} + +func (r *DomainItem) Match(metadata *adapter.InboundContext) bool { + var domainHost string + if metadata.Domain != "" { + domainHost = metadata.Domain + } else { + domainHost = metadata.Destination.Fqdn + } + if domainHost == "" { + return false + } + return r.matcher.Match(strings.ToLower(domainHost)) +} + +func (r *DomainItem) String() string { + return r.description +} diff --git a/route/rule/rule_item_domain_keyword.go b/route/rule/rule_item_domain_keyword.go new file mode 100644 index 00000000..6e19a10c --- /dev/null +++ b/route/rule/rule_item_domain_keyword.go @@ -0,0 +1,47 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*DomainKeywordItem)(nil) + +type DomainKeywordItem struct { + keywords []string +} + +func NewDomainKeywordItem(keywords []string) *DomainKeywordItem { + return &DomainKeywordItem{keywords} +} + +func (r *DomainKeywordItem) Match(metadata *adapter.InboundContext) bool { + var domainHost string + if metadata.Domain != "" { + domainHost = metadata.Domain + } else { + domainHost = metadata.Destination.Fqdn + } + if domainHost == "" { + return false + } + domainHost = strings.ToLower(domainHost) + for _, keyword := range r.keywords { + if strings.Contains(domainHost, keyword) { + return true + } + } + return false +} + +func (r *DomainKeywordItem) String() string { + kLen := len(r.keywords) + if kLen == 1 { + return "domain_keyword=" + r.keywords[0] + } else if kLen > 3 { + return "domain_keyword=[" + strings.Join(r.keywords[:3], " ") + "...]" + } else { + return "domain_keyword=[" + strings.Join(r.keywords, " ") + "]" + } +} diff --git a/route/rule/rule_item_domain_regex.go b/route/rule/rule_item_domain_regex.go new file mode 100644 index 00000000..b9752a45 --- /dev/null +++ b/route/rule/rule_item_domain_regex.go @@ -0,0 +1,61 @@ +package rule + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*DomainRegexItem)(nil) + +type DomainRegexItem struct { + matchers []*regexp.Regexp + description string +} + +func NewDomainRegexItem(expressions []string) (*DomainRegexItem, error) { + matchers := make([]*regexp.Regexp, 0, len(expressions)) + for i, regex := range expressions { + matcher, err := regexp.Compile(regex) + if err != nil { + return nil, E.Cause(err, "parse expression ", i) + } + matchers = append(matchers, matcher) + } + description := "domain_regex=" + eLen := len(expressions) + if eLen == 1 { + description += expressions[0] + } else if eLen > 3 { + description += F.ToString("[", strings.Join(expressions[:3], " "), "]") + } else { + description += F.ToString("[", strings.Join(expressions, " "), "]") + } + return &DomainRegexItem{matchers, description}, nil +} + +func (r *DomainRegexItem) Match(metadata *adapter.InboundContext) bool { + var domainHost string + if metadata.Domain != "" { + domainHost = metadata.Domain + } else { + domainHost = metadata.Destination.Fqdn + } + if domainHost == "" { + return false + } + domainHost = strings.ToLower(domainHost) + for _, matcher := range r.matchers { + if matcher.MatchString(domainHost) { + return true + } + } + return false +} + +func (r *DomainRegexItem) String() string { + return r.description +} diff --git a/route/rule/rule_item_inbound.go b/route/rule/rule_item_inbound.go new file mode 100644 index 00000000..87e84740 --- /dev/null +++ b/route/rule/rule_item_inbound.go @@ -0,0 +1,35 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*InboundItem)(nil) + +type InboundItem struct { + inbounds []string + inboundMap map[string]bool +} + +func NewInboundRule(inbounds []string) *InboundItem { + rule := &InboundItem{inbounds, make(map[string]bool)} + for _, inbound := range inbounds { + rule.inboundMap[inbound] = true + } + return rule +} + +func (r *InboundItem) Match(metadata *adapter.InboundContext) bool { + return r.inboundMap[metadata.Inbound] +} + +func (r *InboundItem) String() string { + if len(r.inbounds) == 1 { + return F.ToString("inbound=", r.inbounds[0]) + } else { + return F.ToString("inbound=[", strings.Join(r.inbounds, " "), "]") + } +} diff --git a/route/rule/rule_item_ip_accept_any.go b/route/rule/rule_item_ip_accept_any.go new file mode 100644 index 00000000..fceebc18 --- /dev/null +++ b/route/rule/rule_item_ip_accept_any.go @@ -0,0 +1,24 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*IPAcceptAnyItem)(nil) + +type IPAcceptAnyItem struct{} + +func NewIPAcceptAnyItem() *IPAcceptAnyItem { + return &IPAcceptAnyItem{} +} + +func (r *IPAcceptAnyItem) Match(metadata *adapter.InboundContext) bool { + if metadata.DestinationAddressMatchFromResponse { + return len(metadata.DNSResponseAddressesForMatch()) > 0 + } + return len(metadata.DestinationAddresses) > 0 +} + +func (r *IPAcceptAnyItem) String() string { + return "ip_accept_any=true" +} diff --git a/route/rule/rule_item_ip_is_private.go b/route/rule/rule_item_ip_is_private.go new file mode 100644 index 00000000..c9688773 --- /dev/null +++ b/route/rule/rule_item_ip_is_private.go @@ -0,0 +1,47 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" + N "github.com/sagernet/sing/common/network" +) + +var _ RuleItem = (*IPIsPrivateItem)(nil) + +type IPIsPrivateItem struct { + isSource bool +} + +func NewIPIsPrivateItem(isSource bool) *IPIsPrivateItem { + return &IPIsPrivateItem{isSource} +} + +func (r *IPIsPrivateItem) Match(metadata *adapter.InboundContext) bool { + if r.isSource { + return !N.IsPublicAddr(metadata.Source.Addr) + } + if metadata.DestinationAddressMatchFromResponse { + for _, destinationAddress := range metadata.DNSResponseAddressesForMatch() { + if !N.IsPublicAddr(destinationAddress) { + return true + } + } + return false + } + if metadata.Destination.Addr.IsValid() { + return !N.IsPublicAddr(metadata.Destination.Addr) + } + for _, destinationAddress := range metadata.DestinationAddresses { + if !N.IsPublicAddr(destinationAddress) { + return true + } + } + return false +} + +func (r *IPIsPrivateItem) String() string { + if r.isSource { + return "source_ip_is_private=true" + } else { + return "ip_is_private=true" + } +} diff --git a/route/rule/rule_item_ipversion.go b/route/rule/rule_item_ipversion.go new file mode 100644 index 00000000..8ab64942 --- /dev/null +++ b/route/rule/rule_item_ipversion.go @@ -0,0 +1,30 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*IPVersionItem)(nil) + +type IPVersionItem struct { + isIPv6 bool +} + +func NewIPVersionItem(isIPv6 bool) *IPVersionItem { + return &IPVersionItem{isIPv6} +} + +func (r *IPVersionItem) Match(metadata *adapter.InboundContext) bool { + return metadata.IPVersion != 0 && metadata.IPVersion == 6 == r.isIPv6 || + metadata.Destination.IsIP() && metadata.Destination.IsIPv6() == r.isIPv6 +} + +func (r *IPVersionItem) String() string { + var versionStr string + if r.isIPv6 { + versionStr = "6" + } else { + versionStr = "4" + } + return "ip_version=" + versionStr +} diff --git a/route/rule/rule_item_network.go b/route/rule/rule_item_network.go new file mode 100644 index 00000000..bfb334d3 --- /dev/null +++ b/route/rule/rule_item_network.go @@ -0,0 +1,42 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*NetworkItem)(nil) + +type NetworkItem struct { + networks []string + networkMap map[string]bool +} + +func NewNetworkItem(networks []string) *NetworkItem { + networkMap := make(map[string]bool) + for _, network := range networks { + networkMap[network] = true + } + return &NetworkItem{ + networks: networks, + networkMap: networkMap, + } +} + +func (r *NetworkItem) Match(metadata *adapter.InboundContext) bool { + return r.networkMap[metadata.Network] +} + +func (r *NetworkItem) String() string { + description := "network=" + + pLen := len(r.networks) + if pLen == 1 { + description += F.ToString(r.networks[0]) + } else { + description += "[" + strings.Join(F.MapToString(r.networks), " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_network_is_constrained.go b/route/rule/rule_item_network_is_constrained.go new file mode 100644 index 00000000..e0368b75 --- /dev/null +++ b/route/rule/rule_item_network_is_constrained.go @@ -0,0 +1,29 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*NetworkIsConstrainedItem)(nil) + +type NetworkIsConstrainedItem struct { + networkManager adapter.NetworkManager +} + +func NewNetworkIsConstrainedItem(networkManager adapter.NetworkManager) *NetworkIsConstrainedItem { + return &NetworkIsConstrainedItem{ + networkManager: networkManager, + } +} + +func (r *NetworkIsConstrainedItem) Match(metadata *adapter.InboundContext) bool { + networkInterface := r.networkManager.DefaultNetworkInterface() + if networkInterface == nil { + return false + } + return networkInterface.Constrained +} + +func (r *NetworkIsConstrainedItem) String() string { + return "network_is_expensive=true" +} diff --git a/route/rule/rule_item_network_is_expensive.go b/route/rule/rule_item_network_is_expensive.go new file mode 100644 index 00000000..83e4f96f --- /dev/null +++ b/route/rule/rule_item_network_is_expensive.go @@ -0,0 +1,29 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*NetworkIsExpensiveItem)(nil) + +type NetworkIsExpensiveItem struct { + networkManager adapter.NetworkManager +} + +func NewNetworkIsExpensiveItem(networkManager adapter.NetworkManager) *NetworkIsExpensiveItem { + return &NetworkIsExpensiveItem{ + networkManager: networkManager, + } +} + +func (r *NetworkIsExpensiveItem) Match(metadata *adapter.InboundContext) bool { + networkInterface := r.networkManager.DefaultNetworkInterface() + if networkInterface == nil { + return false + } + return networkInterface.Expensive +} + +func (r *NetworkIsExpensiveItem) String() string { + return "network_is_expensive=true" +} diff --git a/route/rule/rule_item_network_type.go b/route/rule/rule_item_network_type.go new file mode 100644 index 00000000..31856e70 --- /dev/null +++ b/route/rule/rule_item_network_type.go @@ -0,0 +1,40 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*NetworkTypeItem)(nil) + +type NetworkTypeItem struct { + networkManager adapter.NetworkManager + networkType []C.InterfaceType +} + +func NewNetworkTypeItem(networkManager adapter.NetworkManager, networkType []C.InterfaceType) *NetworkTypeItem { + return &NetworkTypeItem{ + networkManager: networkManager, + networkType: networkType, + } +} + +func (r *NetworkTypeItem) Match(metadata *adapter.InboundContext) bool { + networkInterface := r.networkManager.DefaultNetworkInterface() + if networkInterface == nil { + return false + } + return common.Contains(r.networkType, networkInterface.Type) +} + +func (r *NetworkTypeItem) String() string { + if len(r.networkType) == 1 { + return F.ToString("network_type=", r.networkType[0]) + } else { + return F.ToString("network_type=", "["+strings.Join(F.MapToString(r.networkType), " ")+"]") + } +} diff --git a/route/rule/rule_item_outbound.go b/route/rule/rule_item_outbound.go new file mode 100644 index 00000000..a13d0597 --- /dev/null +++ b/route/rule/rule_item_outbound.go @@ -0,0 +1,46 @@ +package rule + +import ( + "context" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/deprecated" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*OutboundItem)(nil) + +type OutboundItem struct { + outbounds []string + outboundMap map[string]bool + matchAny bool +} + +func NewOutboundRule(ctx context.Context, outbounds []string) *OutboundItem { + deprecated.Report(ctx, deprecated.OptionOutboundDNSRuleItem) + rule := &OutboundItem{outbounds: outbounds, outboundMap: make(map[string]bool)} + for _, outbound := range outbounds { + if outbound == "any" { + rule.matchAny = true + } else { + rule.outboundMap[outbound] = true + } + } + return rule +} + +func (r *OutboundItem) Match(metadata *adapter.InboundContext) bool { + if r.matchAny { + return metadata.Outbound != "" + } + return r.outboundMap[metadata.Outbound] +} + +func (r *OutboundItem) String() string { + if len(r.outbounds) == 1 { + return F.ToString("outbound=", r.outbounds[0]) + } else { + return F.ToString("outbound=[", strings.Join(r.outbounds, " "), "]") + } +} diff --git a/route/rule/rule_item_package_name.go b/route/rule/rule_item_package_name.go new file mode 100644 index 00000000..514768de --- /dev/null +++ b/route/rule/rule_item_package_name.go @@ -0,0 +1,48 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*PackageNameItem)(nil) + +type PackageNameItem struct { + packageNames []string + packageMap map[string]bool +} + +func NewPackageNameItem(packageNameList []string) *PackageNameItem { + rule := &PackageNameItem{ + packageNames: packageNameList, + packageMap: make(map[string]bool), + } + for _, packageName := range packageNameList { + rule.packageMap[packageName] = true + } + return rule +} + +func (r *PackageNameItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || len(metadata.ProcessInfo.AndroidPackageNames) == 0 { + return false + } + for _, packageName := range metadata.ProcessInfo.AndroidPackageNames { + if r.packageMap[packageName] { + return true + } + } + return false +} + +func (r *PackageNameItem) String() string { + var description string + pLen := len(r.packageNames) + if pLen == 1 { + description = "package_name=" + r.packageNames[0] + } else { + description = "package_name=[" + strings.Join(r.packageNames, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_package_name_regex.go b/route/rule/rule_item_package_name_regex.go new file mode 100644 index 00000000..9db4504a --- /dev/null +++ b/route/rule/rule_item_package_name_regex.go @@ -0,0 +1,56 @@ +package rule + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*PackageNameRegexItem)(nil) + +type PackageNameRegexItem struct { + matchers []*regexp.Regexp + description string +} + +func NewPackageNameRegexItem(expressions []string) (*PackageNameRegexItem, error) { + matchers := make([]*regexp.Regexp, 0, len(expressions)) + for i, regex := range expressions { + matcher, err := regexp.Compile(regex) + if err != nil { + return nil, E.Cause(err, "parse expression ", i) + } + matchers = append(matchers, matcher) + } + description := "package_name_regex=" + eLen := len(expressions) + if eLen == 1 { + description += expressions[0] + } else if eLen > 3 { + description += F.ToString("[", strings.Join(expressions[:3], " "), "]") + } else { + description += F.ToString("[", strings.Join(expressions, " "), "]") + } + return &PackageNameRegexItem{matchers, description}, nil +} + +func (r *PackageNameRegexItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || len(metadata.ProcessInfo.AndroidPackageNames) == 0 { + return false + } + for _, matcher := range r.matchers { + for _, packageName := range metadata.ProcessInfo.AndroidPackageNames { + if matcher.MatchString(packageName) { + return true + } + } + } + return false +} + +func (r *PackageNameRegexItem) String() string { + return r.description +} diff --git a/route/rule/rule_item_port.go b/route/rule/rule_item_port.go new file mode 100644 index 00000000..af166ee6 --- /dev/null +++ b/route/rule/rule_item_port.go @@ -0,0 +1,52 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*PortItem)(nil) + +type PortItem struct { + ports []uint16 + portMap map[uint16]bool + isSource bool +} + +func NewPortItem(isSource bool, ports []uint16) *PortItem { + portMap := make(map[uint16]bool) + for _, port := range ports { + portMap[port] = true + } + return &PortItem{ + ports: ports, + portMap: portMap, + isSource: isSource, + } +} + +func (r *PortItem) Match(metadata *adapter.InboundContext) bool { + if r.isSource { + return r.portMap[metadata.Source.Port] + } else { + return r.portMap[metadata.Destination.Port] + } +} + +func (r *PortItem) String() string { + var description string + if r.isSource { + description = "source_port=" + } else { + description = "port=" + } + pLen := len(r.ports) + if pLen == 1 { + description += F.ToString(r.ports[0]) + } else { + description += "[" + strings.Join(F.MapToString(r.ports), " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_port_range.go b/route/rule/rule_item_port_range.go new file mode 100644 index 00000000..980f7d23 --- /dev/null +++ b/route/rule/rule_item_port_range.go @@ -0,0 +1,87 @@ +package rule + +import ( + "strconv" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" +) + +var ErrBadPortRange = E.New("bad port range") + +var _ RuleItem = (*PortRangeItem)(nil) + +type PortRangeItem struct { + isSource bool + portRanges []string + portRangeList []rangeItem +} + +type rangeItem struct { + start uint16 + end uint16 +} + +func NewPortRangeItem(isSource bool, rangeList []string) (*PortRangeItem, error) { + portRangeList := make([]rangeItem, 0, len(rangeList)) + for _, portRange := range rangeList { + if !strings.Contains(portRange, ":") { + return nil, E.Extend(ErrBadPortRange, portRange) + } + subIndex := strings.Index(portRange, ":") + var start, end uint64 + var err error + if subIndex > 0 { + start, err = strconv.ParseUint(portRange[:subIndex], 10, 16) + if err != nil { + return nil, E.Cause(err, E.Extend(ErrBadPortRange, portRange)) + } + } + if subIndex == len(portRange)-1 { + end = 0xFFFF + } else { + end, err = strconv.ParseUint(portRange[subIndex+1:], 10, 16) + if err != nil { + return nil, E.Cause(err, E.Extend(ErrBadPortRange, portRange)) + } + } + portRangeList = append(portRangeList, rangeItem{uint16(start), uint16(end)}) + } + return &PortRangeItem{ + isSource: isSource, + portRanges: rangeList, + portRangeList: portRangeList, + }, nil +} + +func (r *PortRangeItem) Match(metadata *adapter.InboundContext) bool { + var port uint16 + if r.isSource { + port = metadata.Source.Port + } else { + port = metadata.Destination.Port + } + for _, portRange := range r.portRangeList { + if port >= portRange.start && port <= portRange.end { + return true + } + } + return false +} + +func (r *PortRangeItem) String() string { + var description string + if r.isSource { + description = "source_port_range=" + } else { + description = "port_range=" + } + pLen := len(r.portRanges) + if pLen == 1 { + description += r.portRanges[0] + } else { + description += "[" + strings.Join(r.portRanges, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_preferred_by.go b/route/rule/rule_item_preferred_by.go new file mode 100644 index 00000000..42c8a627 --- /dev/null +++ b/route/rule/rule_item_preferred_by.go @@ -0,0 +1,86 @@ +package rule + +import ( + "context" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" +) + +var _ RuleItem = (*PreferredByItem)(nil) + +type PreferredByItem struct { + ctx context.Context + outboundTags []string + outbounds []adapter.OutboundWithPreferredRoutes +} + +func NewPreferredByItem(ctx context.Context, outboundTags []string) *PreferredByItem { + return &PreferredByItem{ + ctx: ctx, + outboundTags: outboundTags, + } +} + +func (r *PreferredByItem) Start() error { + outboundManager := service.FromContext[adapter.OutboundManager](r.ctx) + for _, outboundTag := range r.outboundTags { + rawOutbound, loaded := outboundManager.Outbound(outboundTag) + if !loaded { + return E.New("outbound not found: ", outboundTag) + } + outboundWithPreferredRoutes, withRoutes := rawOutbound.(adapter.OutboundWithPreferredRoutes) + if !withRoutes { + return E.New("outbound type does not support preferred routes: ", rawOutbound.Type()) + } + r.outbounds = append(r.outbounds, outboundWithPreferredRoutes) + } + return nil +} + +func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { + var domainHost string + if metadata.Domain != "" { + domainHost = metadata.Domain + } else { + domainHost = metadata.Destination.Fqdn + } + if domainHost != "" { + for _, outbound := range r.outbounds { + if outbound.PreferredDomain(domainHost) { + return true + } + } + } + if metadata.Destination.IsIP() { + for _, outbound := range r.outbounds { + if outbound.PreferredAddress(metadata.Destination.Addr) { + return true + } + } + } + if len(metadata.DestinationAddresses) > 0 { + for _, address := range metadata.DestinationAddresses { + for _, outbound := range r.outbounds { + if outbound.PreferredAddress(address) { + return true + } + } + } + } + return false +} + +func (r *PreferredByItem) String() string { + description := "preferred_by=" + pLen := len(r.outboundTags) + if pLen == 1 { + description += F.ToString(r.outboundTags[0]) + } else { + description += "[" + strings.Join(F.MapToString(r.outboundTags), " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_process_name.go b/route/rule/rule_item_process_name.go new file mode 100644 index 00000000..fa0f7165 --- /dev/null +++ b/route/rule/rule_item_process_name.go @@ -0,0 +1,44 @@ +package rule + +import ( + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*ProcessItem)(nil) + +type ProcessItem struct { + processes []string + processMap map[string]bool +} + +func NewProcessItem(processNameList []string) *ProcessItem { + rule := &ProcessItem{ + processes: processNameList, + processMap: make(map[string]bool), + } + for _, processName := range processNameList { + rule.processMap[processName] = true + } + return rule +} + +func (r *ProcessItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || metadata.ProcessInfo.ProcessPath == "" { + return false + } + return r.processMap[filepath.Base(metadata.ProcessInfo.ProcessPath)] +} + +func (r *ProcessItem) String() string { + var description string + pLen := len(r.processes) + if pLen == 1 { + description = "process_name=" + r.processes[0] + } else { + description = "process_name=[" + strings.Join(r.processes, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_process_path.go b/route/rule/rule_item_process_path.go new file mode 100644 index 00000000..ac5c6a18 --- /dev/null +++ b/route/rule/rule_item_process_path.go @@ -0,0 +1,54 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" +) + +var _ RuleItem = (*ProcessPathItem)(nil) + +type ProcessPathItem struct { + processes []string + processMap map[string]bool +} + +func NewProcessPathItem(processNameList []string) *ProcessPathItem { + rule := &ProcessPathItem{ + processes: processNameList, + processMap: make(map[string]bool), + } + for _, processName := range processNameList { + rule.processMap[processName] = true + } + return rule +} + +func (r *ProcessPathItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil { + return false + } + if metadata.ProcessInfo.ProcessPath != "" && r.processMap[metadata.ProcessInfo.ProcessPath] { + return true + } + if C.IsAndroid { + for _, packageName := range metadata.ProcessInfo.AndroidPackageNames { + if r.processMap[packageName] { + return true + } + } + } + return false +} + +func (r *ProcessPathItem) String() string { + var description string + pLen := len(r.processes) + if pLen == 1 { + description = "process_path=" + r.processes[0] + } else { + description = "process_path=[" + strings.Join(r.processes, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_process_path_regex.go b/route/rule/rule_item_process_path_regex.go new file mode 100644 index 00000000..76cf67b9 --- /dev/null +++ b/route/rule/rule_item_process_path_regex.go @@ -0,0 +1,54 @@ +package rule + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*ProcessPathRegexItem)(nil) + +type ProcessPathRegexItem struct { + matchers []*regexp.Regexp + description string +} + +func NewProcessPathRegexItem(expressions []string) (*ProcessPathRegexItem, error) { + matchers := make([]*regexp.Regexp, 0, len(expressions)) + for i, regex := range expressions { + matcher, err := regexp.Compile(regex) + if err != nil { + return nil, E.Cause(err, "parse expression ", i) + } + matchers = append(matchers, matcher) + } + description := "process_path_regex=" + eLen := len(expressions) + if eLen == 1 { + description += expressions[0] + } else if eLen > 3 { + description += F.ToString("[", strings.Join(expressions[:3], " "), "]") + } else { + description += F.ToString("[", strings.Join(expressions, " "), "]") + } + return &ProcessPathRegexItem{matchers, description}, nil +} + +func (r *ProcessPathRegexItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || metadata.ProcessInfo.ProcessPath == "" { + return false + } + for _, matcher := range r.matchers { + if matcher.MatchString(metadata.ProcessInfo.ProcessPath) { + return true + } + } + return false +} + +func (r *ProcessPathRegexItem) String() string { + return r.description +} diff --git a/route/rule/rule_item_protocol.go b/route/rule/rule_item_protocol.go new file mode 100644 index 00000000..319b81d5 --- /dev/null +++ b/route/rule/rule_item_protocol.go @@ -0,0 +1,37 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*ProtocolItem)(nil) + +type ProtocolItem struct { + protocols []string + protocolMap map[string]bool +} + +func NewProtocolItem(protocols []string) *ProtocolItem { + protocolMap := make(map[string]bool) + for _, protocol := range protocols { + protocolMap[protocol] = true + } + return &ProtocolItem{ + protocols: protocols, + protocolMap: protocolMap, + } +} + +func (r *ProtocolItem) Match(metadata *adapter.InboundContext) bool { + return r.protocolMap[metadata.Protocol] +} + +func (r *ProtocolItem) String() string { + if len(r.protocols) == 1 { + return F.ToString("protocol=", r.protocols[0]) + } + return F.ToString("protocol=[", strings.Join(r.protocols, " "), "]") +} diff --git a/route/rule/rule_item_query_type.go b/route/rule/rule_item_query_type.go new file mode 100644 index 00000000..36b615f3 --- /dev/null +++ b/route/rule/rule_item_query_type.go @@ -0,0 +1,47 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" +) + +var _ RuleItem = (*QueryTypeItem)(nil) + +type QueryTypeItem struct { + typeList []uint16 + typeMap map[uint16]bool +} + +func NewQueryTypeItem(typeList []option.DNSQueryType) *QueryTypeItem { + rule := &QueryTypeItem{ + typeList: common.Map(typeList, func(it option.DNSQueryType) uint16 { + return uint16(it) + }), + typeMap: make(map[uint16]bool), + } + for _, userId := range rule.typeList { + rule.typeMap[userId] = true + } + return rule +} + +func (r *QueryTypeItem) Match(metadata *adapter.InboundContext) bool { + if metadata.QueryType == 0 { + return false + } + return r.typeMap[metadata.QueryType] +} + +func (r *QueryTypeItem) String() string { + var description string + pLen := len(r.typeList) + if pLen == 1 { + description = "query_type=" + option.DNSQueryTypeToString(r.typeList[0]) + } else { + description = "query_type=[" + strings.Join(common.Map(r.typeList, option.DNSQueryTypeToString), " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_response_rcode.go b/route/rule/rule_item_response_rcode.go new file mode 100644 index 00000000..cac75e80 --- /dev/null +++ b/route/rule/rule_item_response_rcode.go @@ -0,0 +1,26 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" + + "github.com/miekg/dns" +) + +var _ RuleItem = (*DNSResponseRCodeItem)(nil) + +type DNSResponseRCodeItem struct { + rcode int +} + +func NewDNSResponseRCodeItem(rcode int) *DNSResponseRCodeItem { + return &DNSResponseRCodeItem{rcode: rcode} +} + +func (r *DNSResponseRCodeItem) Match(metadata *adapter.InboundContext) bool { + return metadata.DNSResponse != nil && metadata.DNSResponse.Rcode == r.rcode +} + +func (r *DNSResponseRCodeItem) String() string { + return F.ToString("response_rcode=", dns.RcodeToString[r.rcode]) +} diff --git a/route/rule/rule_item_response_record.go b/route/rule/rule_item_response_record.go new file mode 100644 index 00000000..3a2c889b --- /dev/null +++ b/route/rule/rule_item_response_record.go @@ -0,0 +1,63 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + + "github.com/miekg/dns" +) + +var _ RuleItem = (*DNSResponseRecordItem)(nil) + +type DNSResponseRecordItem struct { + field string + records []option.DNSRecordOptions + selector func(*dns.Msg) []dns.RR +} + +func NewDNSResponseRecordItem(field string, records []option.DNSRecordOptions, selector func(*dns.Msg) []dns.RR) *DNSResponseRecordItem { + return &DNSResponseRecordItem{ + field: field, + records: records, + selector: selector, + } +} + +func (r *DNSResponseRecordItem) Match(metadata *adapter.InboundContext) bool { + if metadata.DNSResponse == nil { + return false + } + records := r.selector(metadata.DNSResponse) + for _, expected := range r.records { + for _, record := range records { + if expected.Match(record) { + return true + } + } + } + return false +} + +func (r *DNSResponseRecordItem) String() string { + descriptions := make([]string, 0, len(r.records)) + for _, record := range r.records { + if record.RR != nil { + descriptions = append(descriptions, record.RR.String()) + } + } + return r.field + "=[" + strings.Join(descriptions, " ") + "]" +} + +func dnsResponseAnswers(message *dns.Msg) []dns.RR { + return message.Answer +} + +func dnsResponseNS(message *dns.Msg) []dns.RR { + return message.Ns +} + +func dnsResponseExtra(message *dns.Msg) []dns.RR { + return message.Extra +} diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go new file mode 100644 index 00000000..01364943 --- /dev/null +++ b/route/rule/rule_item_rule_set.go @@ -0,0 +1,89 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*RuleSetItem)(nil) + +type RuleSetItem struct { + router adapter.Router + tagList []string + setList []adapter.RuleSet + ipCidrMatchSource bool + ipCidrAcceptEmpty bool +} + +func NewRuleSetItem(router adapter.Router, tagList []string, ipCIDRMatchSource bool, ipCidrAcceptEmpty bool) *RuleSetItem { + return &RuleSetItem{ + router: router, + tagList: tagList, + ipCidrMatchSource: ipCIDRMatchSource, + ipCidrAcceptEmpty: ipCidrAcceptEmpty, + } +} + +func (r *RuleSetItem) Start() error { + _ = r.Close() + for _, tag := range r.tagList { + ruleSet, loaded := r.router.RuleSet(tag) + if !loaded { + _ = r.Close() + return E.New("rule-set not found: ", tag) + } + ruleSet.IncRef() + r.setList = append(r.setList, ruleSet) + } + return nil +} + +func (r *RuleSetItem) Close() error { + for _, ruleSet := range r.setList { + ruleSet.DecRef() + } + clear(r.setList) + r.setList = nil + return nil +} + +func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { + return !r.matchStates(metadata).isEmpty() +} + +func (r *RuleSetItem) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesWithBase(metadata, 0) +} + +func (r *RuleSetItem) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + var stateSet ruleMatchStateSet + for _, ruleSet := range r.setList { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource + nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) + } + return stateSet +} + +func (r *RuleSetItem) ContainsDestinationIPCIDRRule() bool { + if r.ipCidrMatchSource { + return false + } + return common.Any(r.setList, func(ruleSet adapter.RuleSet) bool { + return ruleSet.Metadata().ContainsIPCIDRRule + }) +} + +func (r *RuleSetItem) String() string { + if len(r.tagList) == 1 { + return F.ToString("rule_set=", r.tagList[0]) + } else { + return F.ToString("rule_set=[", strings.Join(r.tagList, " "), "]") + } +} diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go new file mode 100644 index 00000000..21d2070d --- /dev/null +++ b/route/rule/rule_item_rule_set_test.go @@ -0,0 +1,138 @@ +package rule + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + + "github.com/stretchr/testify/require" + "go4.org/netipx" +) + +type ruleSetItemTestRouter struct { + ruleSets map[string]adapter.RuleSet +} + +func (r *ruleSetItemTestRouter) Start(adapter.StartStage) error { return nil } +func (r *ruleSetItemTestRouter) Close() error { return nil } +func (r *ruleSetItemTestRouter) PreMatch(adapter.InboundContext, tun.DirectRouteContext, time.Duration, bool) (tun.DirectRouteDestination, error) { + return nil, nil +} + +func (r *ruleSetItemTestRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { + return nil +} + +func (r *ruleSetItemTestRouter) RoutePacketConnection(context.Context, N.PacketConn, adapter.InboundContext) error { + return nil +} + +func (r *ruleSetItemTestRouter) RouteConnectionEx(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *ruleSetItemTestRouter) RoutePacketConnectionEx(context.Context, N.PacketConn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *ruleSetItemTestRouter) RuleSet(tag string) (adapter.RuleSet, bool) { + ruleSet, loaded := r.ruleSets[tag] + return ruleSet, loaded +} +func (r *ruleSetItemTestRouter) Rules() []adapter.Rule { return nil } +func (r *ruleSetItemTestRouter) NeedFindProcess() bool { return false } +func (r *ruleSetItemTestRouter) NeedFindNeighbor() bool { return false } +func (r *ruleSetItemTestRouter) NeighborResolver() adapter.NeighborResolver { return nil } +func (r *ruleSetItemTestRouter) AppendTracker(adapter.ConnectionTracker) {} +func (r *ruleSetItemTestRouter) ResetNetwork() {} + +type countingRuleSet struct { + name string + refs atomic.Int32 +} + +func (s *countingRuleSet) Name() string { return s.name } +func (s *countingRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } +func (s *countingRuleSet) PostStart() error { return nil } +func (s *countingRuleSet) Metadata() adapter.RuleSetMetadata { return adapter.RuleSetMetadata{} } +func (s *countingRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } +func (s *countingRuleSet) IncRef() { s.refs.Add(1) } +func (s *countingRuleSet) DecRef() { + if s.refs.Add(-1) < 0 { + panic("rule-set: negative refs") + } +} +func (s *countingRuleSet) Cleanup() {} +func (s *countingRuleSet) RegisterCallback(adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + return nil +} +func (s *countingRuleSet) UnregisterCallback(*list.Element[adapter.RuleSetUpdateCallback]) {} +func (s *countingRuleSet) Close() error { return nil } +func (s *countingRuleSet) Match(*adapter.InboundContext) bool { return true } +func (s *countingRuleSet) String() string { return s.name } +func (s *countingRuleSet) RefCount() int32 { return s.refs.Load() } + +func TestRuleSetItemCloseReleasesRefs(t *testing.T) { + t.Parallel() + + firstSet := &countingRuleSet{name: "first"} + secondSet := &countingRuleSet{name: "second"} + item := NewRuleSetItem(&ruleSetItemTestRouter{ + ruleSets: map[string]adapter.RuleSet{ + "first": firstSet, + "second": secondSet, + }, + }, []string{"first", "second"}, false, false) + + require.NoError(t, item.Start()) + require.EqualValues(t, 1, firstSet.RefCount()) + require.EqualValues(t, 1, secondSet.RefCount()) + + require.NoError(t, item.Close()) + require.Zero(t, firstSet.RefCount()) + require.Zero(t, secondSet.RefCount()) + + require.NoError(t, item.Close()) + require.Zero(t, firstSet.RefCount()) + require.Zero(t, secondSet.RefCount()) +} + +func TestRuleSetItemStartRollbackOnFailure(t *testing.T) { + t.Parallel() + + firstSet := &countingRuleSet{name: "first"} + item := NewRuleSetItem(&ruleSetItemTestRouter{ + ruleSets: map[string]adapter.RuleSet{ + "first": firstSet, + }, + }, []string{"first", "missing"}, false, false) + + err := item.Start() + require.ErrorContains(t, err, "rule-set not found: missing") + require.Zero(t, firstSet.RefCount()) +} + +func TestRuleSetItemRestartKeepsBalancedRefs(t *testing.T) { + t.Parallel() + + firstSet := &countingRuleSet{name: "first"} + item := NewRuleSetItem(&ruleSetItemTestRouter{ + ruleSets: map[string]adapter.RuleSet{ + "first": firstSet, + }, + }, []string{"first"}, false, false) + + require.NoError(t, item.Start()) + require.EqualValues(t, 1, firstSet.RefCount()) + + require.NoError(t, item.Start()) + require.EqualValues(t, 1, firstSet.RefCount()) + + require.NoError(t, item.Close()) + require.Zero(t, firstSet.RefCount()) +} diff --git a/route/rule/rule_item_source_hostname.go b/route/rule/rule_item_source_hostname.go new file mode 100644 index 00000000..0df11c8c --- /dev/null +++ b/route/rule/rule_item_source_hostname.go @@ -0,0 +1,42 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*SourceHostnameItem)(nil) + +type SourceHostnameItem struct { + hostnames []string + hostnameMap map[string]bool +} + +func NewSourceHostnameItem(hostnameList []string) *SourceHostnameItem { + rule := &SourceHostnameItem{ + hostnames: hostnameList, + hostnameMap: make(map[string]bool), + } + for _, hostname := range hostnameList { + rule.hostnameMap[hostname] = true + } + return rule +} + +func (r *SourceHostnameItem) Match(metadata *adapter.InboundContext) bool { + if metadata.SourceHostname == "" { + return false + } + return r.hostnameMap[metadata.SourceHostname] +} + +func (r *SourceHostnameItem) String() string { + var description string + if len(r.hostnames) == 1 { + description = "source_hostname=" + r.hostnames[0] + } else { + description = "source_hostname=[" + strings.Join(r.hostnames, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_source_mac_address.go b/route/rule/rule_item_source_mac_address.go new file mode 100644 index 00000000..feeadb1d --- /dev/null +++ b/route/rule/rule_item_source_mac_address.go @@ -0,0 +1,48 @@ +package rule + +import ( + "net" + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*SourceMACAddressItem)(nil) + +type SourceMACAddressItem struct { + addresses []string + addressMap map[string]bool +} + +func NewSourceMACAddressItem(addressList []string) *SourceMACAddressItem { + rule := &SourceMACAddressItem{ + addresses: addressList, + addressMap: make(map[string]bool), + } + for _, address := range addressList { + parsed, err := net.ParseMAC(address) + if err == nil { + rule.addressMap[parsed.String()] = true + } else { + rule.addressMap[address] = true + } + } + return rule +} + +func (r *SourceMACAddressItem) Match(metadata *adapter.InboundContext) bool { + if metadata.SourceMACAddress == nil { + return false + } + return r.addressMap[metadata.SourceMACAddress.String()] +} + +func (r *SourceMACAddressItem) String() string { + var description string + if len(r.addresses) == 1 { + description = "source_mac_address=" + r.addresses[0] + } else { + description = "source_mac_address=[" + strings.Join(r.addresses, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_user.go b/route/rule/rule_item_user.go new file mode 100644 index 00000000..87a8bff1 --- /dev/null +++ b/route/rule/rule_item_user.go @@ -0,0 +1,40 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*UserItem)(nil) + +type UserItem struct { + users []string + userMap map[string]bool +} + +func NewUserItem(users []string) *UserItem { + userMap := make(map[string]bool) + for _, protocol := range users { + userMap[protocol] = true + } + return &UserItem{ + users: users, + userMap: userMap, + } +} + +func (r *UserItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || metadata.ProcessInfo.UserName == "" { + return false + } + return r.userMap[metadata.ProcessInfo.UserName] +} + +func (r *UserItem) String() string { + if len(r.users) == 1 { + return F.ToString("user=", r.users[0]) + } + return F.ToString("user=[", strings.Join(r.users, " "), "]") +} diff --git a/route/rule/rule_item_user_id.go b/route/rule/rule_item_user_id.go new file mode 100644 index 00000000..57372de0 --- /dev/null +++ b/route/rule/rule_item_user_id.go @@ -0,0 +1,44 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*UserIdItem)(nil) + +type UserIdItem struct { + userIds []int32 + userIdMap map[int32]bool +} + +func NewUserIDItem(userIdList []int32) *UserIdItem { + rule := &UserIdItem{ + userIds: userIdList, + userIdMap: make(map[int32]bool), + } + for _, userId := range userIdList { + rule.userIdMap[userId] = true + } + return rule +} + +func (r *UserIdItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || metadata.ProcessInfo.UserId == -1 { + return false + } + return r.userIdMap[metadata.ProcessInfo.UserId] +} + +func (r *UserIdItem) String() string { + var description string + pLen := len(r.userIds) + if pLen == 1 { + description = "user_id=" + F.ToString(r.userIds[0]) + } else { + description = "user_id=[" + strings.Join(F.MapToString(r.userIds), " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_wifi_bssid.go b/route/rule/rule_item_wifi_bssid.go new file mode 100644 index 00000000..8f887322 --- /dev/null +++ b/route/rule/rule_item_wifi_bssid.go @@ -0,0 +1,39 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*WIFIBSSIDItem)(nil) + +type WIFIBSSIDItem struct { + bssidList []string + bssidMap map[string]bool + networkManager adapter.NetworkManager +} + +func NewWIFIBSSIDItem(networkManager adapter.NetworkManager, bssidList []string) *WIFIBSSIDItem { + bssidMap := make(map[string]bool) + for _, bssid := range bssidList { + bssidMap[bssid] = true + } + return &WIFIBSSIDItem{ + bssidList, + bssidMap, + networkManager, + } +} + +func (r *WIFIBSSIDItem) Match(metadata *adapter.InboundContext) bool { + return r.bssidMap[r.networkManager.WIFIState().BSSID] +} + +func (r *WIFIBSSIDItem) String() string { + if len(r.bssidList) == 1 { + return F.ToString("wifi_bssid=", r.bssidList[0]) + } + return F.ToString("wifi_bssid=[", strings.Join(r.bssidList, " "), "]") +} diff --git a/route/rule/rule_item_wifi_ssid.go b/route/rule/rule_item_wifi_ssid.go new file mode 100644 index 00000000..ab9fdd88 --- /dev/null +++ b/route/rule/rule_item_wifi_ssid.go @@ -0,0 +1,39 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*WIFISSIDItem)(nil) + +type WIFISSIDItem struct { + ssidList []string + ssidMap map[string]bool + networkManager adapter.NetworkManager +} + +func NewWIFISSIDItem(networkManager adapter.NetworkManager, ssidList []string) *WIFISSIDItem { + ssidMap := make(map[string]bool) + for _, ssid := range ssidList { + ssidMap[ssid] = true + } + return &WIFISSIDItem{ + ssidList, + ssidMap, + networkManager, + } +} + +func (r *WIFISSIDItem) Match(metadata *adapter.InboundContext) bool { + return r.ssidMap[r.networkManager.WIFIState().SSID] +} + +func (r *WIFISSIDItem) String() string { + if len(r.ssidList) == 1 { + return F.ToString("wifi_ssid=", r.ssidList[0]) + } + return F.ToString("wifi_ssid=[", strings.Join(r.ssidList, " "), "]") +} diff --git a/route/rule/rule_nested_action.go b/route/rule/rule_nested_action.go new file mode 100644 index 00000000..44e58839 --- /dev/null +++ b/route/rule/rule_nested_action.go @@ -0,0 +1,71 @@ +package rule + +import ( + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ValidateNoNestedRuleActions(rule option.Rule) error { + return validateNoNestedRuleActions(rule, false) +} + +func ValidateNoNestedDNSRuleActions(rule option.DNSRule) error { + return validateNoNestedDNSRuleActions(rule, false) +} + +func validateNoNestedRuleActions(rule option.Rule, nested bool) error { + if nested && ruleHasConfiguredAction(rule) { + return E.New(option.RouteRuleActionNestedUnsupportedMessage) + } + if rule.Type != C.RuleTypeLogical { + return nil + } + for i, subRule := range rule.LogicalOptions.Rules { + err := validateNoNestedRuleActions(subRule, true) + if err != nil { + return E.Cause(err, "sub rule[", i, "]") + } + } + return nil +} + +func validateNoNestedDNSRuleActions(rule option.DNSRule, nested bool) error { + if nested && dnsRuleHasConfiguredAction(rule) { + return E.New(option.DNSRuleActionNestedUnsupportedMessage) + } + if rule.Type != C.RuleTypeLogical { + return nil + } + for i, subRule := range rule.LogicalOptions.Rules { + err := validateNoNestedDNSRuleActions(subRule, true) + if err != nil { + return E.Cause(err, "sub rule[", i, "]") + } + } + return nil +} + +func ruleHasConfiguredAction(rule option.Rule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return !reflect.DeepEqual(rule.DefaultOptions.RuleAction, option.RuleAction{}) + case C.RuleTypeLogical: + return !reflect.DeepEqual(rule.LogicalOptions.RuleAction, option.RuleAction{}) + default: + return false + } +} + +func dnsRuleHasConfiguredAction(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return !reflect.DeepEqual(rule.DefaultOptions.DNSRuleAction, option.DNSRuleAction{}) + case C.RuleTypeLogical: + return !reflect.DeepEqual(rule.LogicalOptions.DNSRuleAction, option.DNSRuleAction{}) + default: + return false + } +} diff --git a/route/rule/rule_nested_action_test.go b/route/rule/rule_nested_action_test.go new file mode 100644 index 00000000..f895b892 --- /dev/null +++ b/route/rule/rule_nested_action_test.go @@ -0,0 +1,88 @@ +package rule + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + + "github.com/stretchr/testify/require" +) + +func TestNewRuleRejectsNestedRuleAction(t *testing.T) { + t.Parallel() + + _, err := NewRule(context.Background(), log.NewNOPFactory().NewLogger("router"), option.Rule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalRule{ + RawLogicalRule: option.RawLogicalRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.Rule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "direct", + }, + }, + }, + }}, + }, + }, + }, false) + require.ErrorContains(t, err, option.RouteRuleActionNestedUnsupportedMessage) +} + +func TestNewDNSRuleRejectsNestedRuleAction(t *testing.T) { + t.Parallel() + + _, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), option.DNSRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + }, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + }, + }, + }, + }, true, false) + require.ErrorContains(t, err, option.DNSRuleActionNestedUnsupportedMessage) +} + +func TestNewDNSRuleRejectsReplyRejectMethod(t *testing.T) { + t.Parallel() + + _, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), option.DNSRule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: []string{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodReply, + }, + }, + }, + }, false, false) + require.ErrorContains(t, err, "reject method `reply` is not supported for DNS rules") +} diff --git a/route/rule/rule_network_interface_address.go b/route/rule/rule_network_interface_address.go new file mode 100644 index 00000000..c699c593 --- /dev/null +++ b/route/rule/rule_network_interface_address.go @@ -0,0 +1,64 @@ +package rule + +import ( + "net/netip" + "strings" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +var _ RuleItem = (*NetworkInterfaceAddressItem)(nil) + +type NetworkInterfaceAddressItem struct { + networkManager adapter.NetworkManager + interfaceAddresses map[C.InterfaceType][]netip.Prefix + description string +} + +func NewNetworkInterfaceAddressItem(networkManager adapter.NetworkManager, interfaceAddresses *badjson.TypedMap[option.InterfaceType, badoption.Listable[*badoption.Prefixable]]) *NetworkInterfaceAddressItem { + item := &NetworkInterfaceAddressItem{ + networkManager: networkManager, + interfaceAddresses: make(map[C.InterfaceType][]netip.Prefix, interfaceAddresses.Size()), + } + var entryDescriptions []string + for _, entry := range interfaceAddresses.Entries() { + prefixes := make([]netip.Prefix, 0, len(entry.Value)) + for _, prefixable := range entry.Value { + prefixes = append(prefixes, prefixable.Build(netip.Prefix{})) + } + item.interfaceAddresses[entry.Key.Build()] = prefixes + entryDescriptions = append(entryDescriptions, entry.Key.Build().String()+"="+strings.Join(common.Map(prefixes, netip.Prefix.String), ",")) + } + item.description = "network_interface_address=[" + strings.Join(entryDescriptions, " ") + "]" + return item +} + +func (r *NetworkInterfaceAddressItem) Match(metadata *adapter.InboundContext) bool { + interfaces := r.networkManager.NetworkInterfaces() +match: + for ifType, addresses := range r.interfaceAddresses { + for _, networkInterface := range interfaces { + if networkInterface.Type != ifType { + continue + } + if common.Any(networkInterface.Addresses, func(it netip.Prefix) bool { + return common.Any(addresses, func(prefix netip.Prefix) bool { + return prefix.Overlaps(it) + }) + }) { + continue match + } + } + return false + } + return true +} + +func (r *NetworkInterfaceAddressItem) String() string { + return r.description +} diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go new file mode 100644 index 00000000..7c82b602 --- /dev/null +++ b/route/rule/rule_set.go @@ -0,0 +1,93 @@ +package rule + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" + + "go4.org/netipx" +) + +func NewRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (adapter.RuleSet, error) { + switch options.Type { + case C.RuleSetTypeInline, C.RuleSetTypeLocal, "": + return NewLocalRuleSet(ctx, logger, options) + case C.RuleSetTypeRemote: + return NewRemoteRuleSet(ctx, logger, options), nil + default: + return nil, E.New("unknown rule-set type: ", options.Type) + } +} + +func extractIPSetFromRule(rawRule adapter.HeadlessRule) []*netipx.IPSet { + switch rule := rawRule.(type) { + case *DefaultHeadlessRule: + return common.FlatMap(rule.destinationIPCIDRItems, func(rawItem RuleItem) []*netipx.IPSet { + switch item := rawItem.(type) { + case *IPCIDRItem: + return []*netipx.IPSet{item.ipSet} + default: + return nil + } + }) + case *LogicalHeadlessRule: + return common.FlatMap(rule.rules, extractIPSetFromRule) + default: + panic("unexpected rule type") + } +} + +func HasHeadlessRule(rules []option.HeadlessRule, cond func(rule option.DefaultHeadlessRule) bool) bool { + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if cond(rule.DefaultOptions) { + return true + } + case C.RuleTypeLogical: + if HasHeadlessRule(rule.LogicalOptions.Rules, cond) { + return true + } + } + } + return false +} + +func isProcessHeadlessRule(rule option.DefaultHeadlessRule) bool { + return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.PackageNameRegex) > 0 +} + +func isWIFIHeadlessRule(rule option.DefaultHeadlessRule) bool { + return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 +} + +func isIPCIDRHeadlessRule(rule option.DefaultHeadlessRule) bool { + return len(rule.IPCIDR) > 0 || rule.IPSet != nil +} + +func isDNSQueryTypeHeadlessRule(rule option.DefaultHeadlessRule) bool { + return len(rule.QueryType) > 0 +} + +func buildRuleSetMetadata(headlessRules []option.HeadlessRule) adapter.RuleSetMetadata { + return adapter.RuleSetMetadata{ + ContainsProcessRule: HasHeadlessRule(headlessRules, isProcessHeadlessRule), + ContainsWIFIRule: HasHeadlessRule(headlessRules, isWIFIHeadlessRule), + ContainsIPCIDRRule: HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule), + ContainsDNSQueryTypeRule: HasHeadlessRule(headlessRules, isDNSQueryTypeHeadlessRule), + } +} + +func validateRuleSetMetadataUpdate(ctx context.Context, tag string, metadata adapter.RuleSetMetadata) error { + validator := service.FromContext[adapter.DNSRuleSetUpdateValidator](ctx) + if validator == nil { + return nil + } + return validator.ValidateRuleSetMetadataUpdate(tag, metadata) +} diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go new file mode 100644 index 00000000..5408615f --- /dev/null +++ b/route/rule/rule_set_local.go @@ -0,0 +1,221 @@ +package rule + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service/filemanager" + + "go4.org/netipx" +) + +var _ adapter.RuleSet = (*LocalRuleSet)(nil) + +type LocalRuleSet struct { + ctx context.Context + logger logger.Logger + tag string + access sync.RWMutex + rules []adapter.HeadlessRule + metadata adapter.RuleSetMetadata + fileFormat string + watcher *fswatch.Watcher + callbacks list.List[adapter.RuleSetUpdateCallback] + refs atomic.Int32 +} + +func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) { + ruleSet := &LocalRuleSet{ + ctx: ctx, + logger: logger, + tag: options.Tag, + fileFormat: options.Format, + } + if options.Type == C.RuleSetTypeInline { + if len(options.InlineOptions.Rules) == 0 { + return nil, E.New("empty inline rule-set") + } + err := ruleSet.reloadRules(options.InlineOptions.Rules) + if err != nil { + return nil, err + } + } else { + filePath := filemanager.BasePath(ctx, options.LocalOptions.Path) + filePath, _ = filepath.Abs(filePath) + err := ruleSet.reloadFile(filePath) + if err != nil { + return nil, err + } + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{filePath}, + Callback: func(path string) { + uErr := ruleSet.reloadFile(path) + if uErr != nil { + logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag)) + } + }, + }) + if err != nil { + return nil, err + } + ruleSet.watcher = watcher + } + return ruleSet, nil +} + +func (s *LocalRuleSet) Name() string { + return s.tag +} + +func (s *LocalRuleSet) String() string { + return strings.Join(F.MapToString(s.rules), " ") +} + +func (s *LocalRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + s.logger.Error(E.Cause(err, "watch rule-set file")) + } + } + return nil +} + +func (s *LocalRuleSet) reloadFile(path string) error { + var ruleSet option.PlainRuleSetCompat + switch s.fileFormat { + case C.RuleSetFormatSource, "": + content, err := os.ReadFile(path) + if err != nil { + return err + } + ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + + case C.RuleSetFormatBinary: + setFile, err := os.Open(path) + if err != nil { + return err + } + ruleSet, err = srs.Read(setFile, false) + if err != nil { + return err + } + default: + return E.New("unknown rule-set format: ", s.fileFormat) + } + plainRuleSet, err := ruleSet.Upgrade() + if err != nil { + return err + } + return s.reloadRules(plainRuleSet.Rules) +} + +func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error { + rules := make([]adapter.HeadlessRule, len(headlessRules)) + var err error + for i, ruleOptions := range headlessRules { + rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) + if err != nil { + return E.Cause(err, "parse rule_set.rules.[", i, "]") + } + } + metadata := buildRuleSetMetadata(headlessRules) + err = validateRuleSetMetadataUpdate(s.ctx, s.tag, metadata) + if err != nil { + return err + } + s.access.Lock() + s.rules = rules + s.metadata = metadata + callbacks := s.callbacks.Array() + s.access.Unlock() + for _, callback := range callbacks { + callback(s) + } + return nil +} + +func (s *LocalRuleSet) PostStart() error { + return nil +} + +func (s *LocalRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.RLock() + defer s.access.RUnlock() + return s.metadata +} + +func (s *LocalRuleSet) ExtractIPSet() []*netipx.IPSet { + s.access.RLock() + defer s.access.RUnlock() + return common.FlatMap(s.rules, extractIPSetFromRule) +} + +func (s *LocalRuleSet) IncRef() { + s.refs.Add(1) +} + +func (s *LocalRuleSet) DecRef() { + if s.refs.Add(-1) < 0 { + panic("rule-set: negative refs") + } +} + +func (s *LocalRuleSet) Cleanup() { + if s.refs.Load() == 0 { + s.rules = nil + } +} + +func (s *LocalRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.PushBack(callback) +} + +func (s *LocalRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { + s.access.Lock() + defer s.access.Unlock() + s.callbacks.Remove(element) +} + +func (s *LocalRuleSet) Close() error { + s.rules = nil + return common.Close(common.PtrOrNil(s.watcher)) +} + +func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { + return !s.matchStates(metadata).isEmpty() +} + +func (s *LocalRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return s.matchStatesWithBase(metadata, 0) +} + +func (s *LocalRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + var stateSet ruleMatchStateSet + for _, rule := range s.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) + } + return stateSet +} diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go new file mode 100644 index 00000000..53d353b3 --- /dev/null +++ b/route/rule/rule_set_remote.go @@ -0,0 +1,343 @@ +package rule + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" + + "go4.org/netipx" +) + +var _ adapter.RuleSet = (*RemoteRuleSet)(nil) + +type RemoteRuleSet struct { + ctx context.Context + cancel context.CancelFunc + logger logger.ContextLogger + outbound adapter.OutboundManager + options option.RuleSet + updateInterval time.Duration + dialer N.Dialer + access sync.RWMutex + rules []adapter.HeadlessRule + metadata adapter.RuleSetMetadata + lastUpdated time.Time + lastEtag string + updateTicker *time.Ticker + cacheFile adapter.CacheFile + pauseManager pause.Manager + callbacks list.List[adapter.RuleSetUpdateCallback] + refs atomic.Int32 +} + +func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet { + ctx, cancel := context.WithCancel(ctx) + var updateInterval time.Duration + if options.RemoteOptions.UpdateInterval > 0 { + updateInterval = time.Duration(options.RemoteOptions.UpdateInterval) + } else { + updateInterval = 24 * time.Hour + } + return &RemoteRuleSet{ + ctx: ctx, + cancel: cancel, + outbound: service.FromContext[adapter.OutboundManager](ctx), + logger: logger, + options: options, + updateInterval: updateInterval, + pauseManager: service.FromContext[pause.Manager](ctx), + } +} + +func (s *RemoteRuleSet) Name() string { + return s.options.Tag +} + +func (s *RemoteRuleSet) String() string { + return strings.Join(F.MapToString(s.rules), " ") +} + +func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { + s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) + var dialer N.Dialer + if s.options.RemoteOptions.DownloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.options.RemoteOptions.DownloadDetour) + if !loaded { + return E.New("download detour not found: ", s.options.RemoteOptions.DownloadDetour) + } + dialer = outbound + } else { + dialer = s.outbound.Default() + } + s.dialer = dialer + if s.cacheFile != nil { + if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { + err := s.loadBytes(savedSet.Content) + if err != nil { + return E.Cause(err, "restore cached rule-set") + } + s.lastUpdated = savedSet.LastUpdated + s.lastEtag = savedSet.LastEtag + } + } + if s.lastUpdated.IsZero() { + err := s.fetch(ctx, startContext) + if err != nil { + return E.Cause(err, "initial rule-set: ", s.options.Tag) + } + } + s.updateTicker = time.NewTicker(s.updateInterval) + return nil +} + +func (s *RemoteRuleSet) PostStart() error { + go s.loopUpdate() + return nil +} + +func (s *RemoteRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.RLock() + defer s.access.RUnlock() + return s.metadata +} + +func (s *RemoteRuleSet) ExtractIPSet() []*netipx.IPSet { + s.access.RLock() + defer s.access.RUnlock() + return common.FlatMap(s.rules, extractIPSetFromRule) +} + +func (s *RemoteRuleSet) IncRef() { + s.refs.Add(1) +} + +func (s *RemoteRuleSet) DecRef() { + if s.refs.Add(-1) < 0 { + panic("rule-set: negative refs") + } +} + +func (s *RemoteRuleSet) Cleanup() { + if s.refs.Load() == 0 { + s.rules = nil + } +} + +func (s *RemoteRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.PushBack(callback) +} + +func (s *RemoteRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { + s.access.Lock() + defer s.access.Unlock() + s.callbacks.Remove(element) +} + +func (s *RemoteRuleSet) loadBytes(content []byte) error { + var ( + ruleSet option.PlainRuleSetCompat + err error + ) + switch s.options.Format { + case C.RuleSetFormatSource: + ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + case C.RuleSetFormatBinary: + ruleSet, err = srs.Read(bytes.NewReader(content), false) + if err != nil { + return err + } + default: + return E.New("unknown rule-set format: ", s.options.Format) + } + plainRuleSet, err := ruleSet.Upgrade() + if err != nil { + return err + } + rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules)) + for i, ruleOptions := range plainRuleSet.Rules { + rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) + if err != nil { + return E.Cause(err, "parse rule_set.rules.[", i, "]") + } + } + metadata := buildRuleSetMetadata(plainRuleSet.Rules) + err = validateRuleSetMetadataUpdate(s.ctx, s.options.Tag, metadata) + if err != nil { + return err + } + s.access.Lock() + s.metadata = metadata + s.rules = rules + callbacks := s.callbacks.Array() + s.access.Unlock() + for _, callback := range callbacks { + callback(s) + } + return nil +} + +func (s *RemoteRuleSet) loopUpdate() { + if time.Since(s.lastUpdated) > s.updateInterval { + err := s.fetch(s.ctx, nil) + if err != nil { + s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + } else if s.refs.Load() == 0 { + s.rules = nil + } + } + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.updateTicker.C: + s.updateOnce() + } + } +} + +func (s *RemoteRuleSet) updateOnce() { + err := s.fetch(s.ctx, nil) + if err != nil { + s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + } else if s.refs.Load() == 0 { + s.rules = nil + } +} + +func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPStartContext) error { + s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) + var httpClient *http.Client + if startContext != nil { + httpClient = startContext.HTTPClient(s.options.RemoteOptions.DownloadDetour, s.dialer) + } else { + httpClient = &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(s.ctx), + RootCAs: adapter.RootPoolFromContext(s.ctx), + }, + }, + } + } + request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) + if err != nil { + return err + } + if s.lastEtag != "" { + request.Header.Set("If-None-Match", s.lastEtag) + } + response, err := httpClient.Do(request.WithContext(ctx)) + if err != nil { + return err + } + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.lastUpdated = time.Now() + if s.cacheFile != nil { + savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) + if savedRuleSet != nil { + savedRuleSet.LastUpdated = s.lastUpdated + err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) + if err != nil { + s.logger.Error("save rule-set updated time: ", err) + return nil + } + } + } + s.logger.Info("update rule-set ", s.options.Tag, ": not modified") + return nil + default: + return E.New("unexpected status: ", response.Status) + } + content, err := io.ReadAll(response.Body) + if err != nil { + response.Body.Close() + return err + } + err = s.loadBytes(content) + if err != nil { + response.Body.Close() + return err + } + response.Body.Close() + eTagHeader := response.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader + } + s.lastUpdated = time.Now() + if s.cacheFile != nil { + err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + Content: content, + LastEtag: s.lastEtag, + }) + if err != nil { + s.logger.Error("save rule-set cache: ", err) + } + } + s.logger.Info("updated rule-set ", s.options.Tag) + return nil +} + +func (s *RemoteRuleSet) Close() error { + s.rules = nil + s.cancel() + if s.updateTicker != nil { + s.updateTicker.Stop() + } + return nil +} + +func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { + return !s.matchStates(metadata).isEmpty() +} + +func (s *RemoteRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return s.matchStatesWithBase(metadata, 0) +} + +func (s *RemoteRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + var stateSet ruleMatchStateSet + for _, rule := range s.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) + } + return stateSet +} diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go new file mode 100644 index 00000000..2fc559d2 --- /dev/null +++ b/route/rule/rule_set_semantics_test.go @@ -0,0 +1,1261 @@ +package rule + +import ( + "context" + "net" + "net/netip" + "strings" + "testing" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/convertor/adguard" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + slogger "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestRouteRuleSetMergeDestinationAddressGroup(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + metadata adapter.InboundContext + inner adapter.HeadlessRule + }{ + { + name: "domain", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, []string{"www.example.com"}, nil) }), + }, + { + name: "domain_suffix", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, nil, []string{"example.com"}) }), + }, + { + name: "domain_keyword", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationKeywordItem(rule, []string{"example"}) }), + }, + { + name: "domain_regex", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationRegexItem(t, rule, []string{`^www\.example\.com$`}) }), + }, + { + name: "ip_cidr", + metadata: func() adapter.InboundContext { + metadata := testMetadata("lookup.example") + metadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("8.8.8.8")} + return metadata + }(), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"8.8.8.0/24"}) + }), + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("merge-destination", testCase.inner) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&testCase.metadata)) + }) + } +} + +func TestRouteRuleSetMergeSourceAndPortGroups(t *testing.T) { + t.Parallel() + t.Run("source address", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-address", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourceAddressItem(t, rule, []string{"10.0.0.0/8"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("source address via ruleset ipcidr match source", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-address-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"10.0.0.0/8"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{ + setList: []adapter.RuleSet{ruleSet}, + ipCidrMatchSource: true, + }) + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("destination port", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-destination-port", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{443}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationPortItem(rule, []uint16{8443}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("destination port range", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-destination-port-range", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationPortItem(rule, []uint16{8443}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("source port", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-port", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{2000}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("source port range", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-port-range", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortRangeItem(t, rule, []string{"900:1100"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{2000}) + }) + require.True(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetOuterGroupedStateMergesIntoSameGroup(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + metadata adapter.InboundContext + buildOuter func(*testing.T, *abstractDefaultRule) + buildInner func(*testing.T, *abstractDefaultRule) + }{ + { + name: "destination address", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + }, + }, + { + name: "source address", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourceAddressItem(t, rule, []string{"10.0.0.0/8"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }, + }, + { + name: "source port", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourcePortItem(rule, []uint16{1000}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourcePortItem(rule, []uint16{2000}) + }, + }, + { + name: "destination port", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationPortItem(rule, []uint16{443}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationPortItem(rule, []uint16{8443}) + }, + }, + { + name: "destination ip cidr", + metadata: func() adapter.InboundContext { + metadata := testMetadata("lookup.example") + metadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("203.0.113.1")} + return metadata + }(), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"198.51.100.0/24"}) + }, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("outer-merge-"+testCase.name, headlessDefaultRule(t, func(rule *abstractDefaultRule) { + testCase.buildInner(t, rule) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + testCase.buildOuter(t, rule) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&testCase.metadata)) + }) + } +} + +func TestRouteRuleSetOtherFieldsStayAnd(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("other-fields-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }) + require.False(t, rule.Match(&metadata)) +} + +func TestRouteRuleSetMergedBranchKeepsAndConstraints(t *testing.T) { + t.Parallel() + t.Run("outer group does not bypass inner non grouped condition", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("network-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("outer group does not satisfy different grouped branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("different-group", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetOrSemantics(t *testing.T) { + t.Parallel() + t.Run("later ruleset can satisfy outer group", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + emptyStateSet := newLocalRuleSetForTest("network-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + })) + destinationStateSet := newLocalRuleSetForTest("domain-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptyStateSet, destinationStateSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("later rule in same set can satisfy outer group", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "rule-set-or", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("cross ruleset union is not allowed", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + sourceStateSet := newLocalRuleSetForTest("source-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + })) + destinationStateSet := newLocalRuleSetForTest("destination-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{sourceStateSet, destinationStateSet}}) + addSourcePortItem(rule, []uint16{2000}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetLogicalSemantics(t *testing.T) { + t.Parallel() + t.Run("logical or keeps all successful branch states", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-or", headlessLogicalRule( + C.LogicalTypeOr, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical and unions child states", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-and", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + addSourcePortItem(rule, []uint16{2000}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("invert success does not contribute positive state", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"cn"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { + t.Parallel() + t.Run("default invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("default invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert-network", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-invert-grouped", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-invert-network", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetNoLeakageRegressions(t *testing.T) { + t.Parallel() + t.Run("same ruleset failed branch does not leak", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "same-set", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addSourcePortItem(rule, []uint16{1}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + addSourcePortItem(rule, []uint16{1000}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("adguard exclusion remains isolated across rulesets", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("im.qq.com") + excludeSet := newLocalRuleSetForTest("adguard", mustAdGuardRule(t, "@@||im.qq.com^\n||whatever1.com^\n")) + otherSet := newLocalRuleSetForTest("other", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"whatever2.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{excludeSet, otherSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestDefaultRuleDoesNotReuseGroupedMatchCacheAcrossEvaluations(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }) + require.True(t, rule.Match(&metadata)) + + metadata.Destination.Fqdn = "www.example.org" + require.False(t, rule.Match(&metadata)) +} + +func TestRouteRuleSetRemoteUsesSameSemantics(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newRemoteRuleSetForTest( + "remote", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&metadata)) +} + +func TestDNSRuleSetSemantics(t *testing.T) { + t.Parallel() + t.Run("outer destination group merges into matching ruleset branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.baidu.com") + ruleSet := newLocalRuleSetForTest("dns-merged-branch", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"baidu.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("outer destination group does not bypass ruleset non grouped condition", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-network-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("outer destination group stays outside inverted grouped branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.baidu.com") + ruleSet := newLocalRuleSetForTest("dns-invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"baidu.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("outer destination group stays outside inverted logical branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-logical-invert-network", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }), + )) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("match address limit merges destination group", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-merge", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + }) + t.Run("dns keeps ruleset or semantics", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + emptyStateSet := newLocalRuleSetForTest("dns-empty", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + })) + destinationStateSet := newLocalRuleSetForTest("dns-destination", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptyStateSet, destinationStateSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + }) + t.Run("ruleset ip cidr flags stay scoped", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{ + setList: []adapter.RuleSet{ruleSet}, + ipCidrAcceptEmpty: true, + }) + }) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + require.False(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest())) + require.False(t, metadata.IPCIDRMatchSource) + require.False(t, metadata.IPCIDRAcceptEmpty) + }) + t.Run("pre lookup ruleset only deferred fields fail closed", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + ruleSet := newLocalRuleSetForTest("dns-prelookup-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + // This is accepted without match_response so mixed rule_set deployments keep + // working; the destination-IP-only branch simply cannot match before a DNS + // response is available. + require.False(t, rule.Match(&metadata)) + }) + t.Run("pre lookup ruleset destination cidr does not fall back to other predicates", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + ruleSet := newLocalRuleSetForTest("dns-prelookup-network-and-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("pre lookup mixed ruleset still matches non response branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "dns-prelookup-mixed", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + ) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + // Destination-IP predicates inside rule_set fail closed before the DNS response, + // but they must not force validation errors or suppress sibling non-response + // branches. + require.True(t, rule.Match(&metadata)) + }) +} + +func TestDNSMatchResponseRuleSetDestinationCIDRUsesDNSResponse(t *testing.T) { + t.Parallel() + + ruleSet := newLocalRuleSetForTest("dns-response-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + rule.matchResponse = true + + matchedMetadata := testMetadata("lookup.example") + matchedMetadata.DNSResponse = dnsResponseForTest(netip.MustParseAddr("203.0.113.1")) + require.True(t, rule.Match(&matchedMetadata)) + require.Empty(t, matchedMetadata.DestinationAddresses) + + unmatchedMetadata := testMetadata("lookup.example") + unmatchedMetadata.DNSResponse = dnsResponseForTest(netip.MustParseAddr("8.8.8.8")) + require.False(t, rule.Match(&unmatchedMetadata)) +} + +func TestDNSMatchResponseMissingResponseUsesBooleanSemantics(t *testing.T) { + t.Parallel() + + t.Run("plain rule remains false", func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) {}) + rule.matchResponse = true + + metadata := testMetadata("lookup.example") + require.False(t, rule.Match(&metadata)) + }) + + t.Run("invert rule becomes true", func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + }) + rule.matchResponse = true + + metadata := testMetadata("lookup.example") + require.True(t, rule.Match(&metadata)) + }) + + t.Run("logical wrapper respects inverted child", func(t *testing.T) { + t.Parallel() + + nestedRule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + }) + nestedRule.matchResponse = true + + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{nestedRule}, + mode: C.LogicalTypeAnd, + }, + } + + metadata := testMetadata("lookup.example") + require.True(t, logicalRule.Match(&metadata)) + }) +} + +func TestDNSAddressLimitIgnoresDestinationAddresses(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedResponse *mDNS.Msg + unmatchedResponse *mDNS.Msg + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("10.0.0.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(), + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + testCase.build(t, rule) + }) + + mismatchMetadata := testMetadata("lookup.example") + mismatchMetadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("203.0.113.1")} + require.False(t, rule.MatchAddressLimit(&mismatchMetadata, testCase.unmatchedResponse)) + + matchMetadata := testMetadata("lookup.example") + matchMetadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("8.8.8.8")} + require.True(t, rule.MatchAddressLimit(&matchMetadata, testCase.matchedResponse)) + }) + } +} + +func TestDNSLegacyAddressLimitPreLookupDefersDirectRules(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedResponse *mDNS.Msg + unmatchedResponse *mDNS.Msg + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("10.0.0.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(), + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + testCase.build(t, rule) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&matchedMetadata, testCase.matchedResponse)) + + unmatchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&unmatchedMetadata, testCase.unmatchedResponse)) + }) + } +} + +func TestDNSLegacyAddressLimitPreLookupDefersRuleSetDestinationCIDR(t *testing.T) { + t.Parallel() + + ruleSet := newLocalRuleSetForTest("dns-legacy-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) +} + +func TestDNSLegacyLogicalAddressLimitPreLookupDefersNestedRules(t *testing.T) { + t.Parallel() + + nestedRule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationIPIsPrivateItem(rule) + }) + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{nestedRule}, + mode: C.LogicalTypeAnd, + }, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("10.0.0.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.False(t, logicalRule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) +} + +func TestDNSLegacyInvertAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedAddrs []netip.Addr + unmatchedAddrs []netip.Addr + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + testCase.build(t, rule) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(testCase.matchedAddrs...))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(testCase.unmatchedAddrs...))) + }) + } +} + +func TestDNSLegacyInvertLogicalAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + + t.Run("inverted deferred child does not suppress branch", func(t *testing.T) { + t.Parallel() + + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{ + dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationIPIsPrivateItem(rule) + }), + }, + mode: C.LogicalTypeAnd, + }, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) + }) +} + +func TestDNSLegacyInvertRuleSetAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + + ruleSet := newLocalRuleSetForTest("dns-legacy-invert-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) +} + +func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedAddrs []netip.Addr + unmatchedAddrs []netip.Addr + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + testCase.build(t, rule) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.Match(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + matchedMetadata.DestinationAddresses = testCase.matchedAddrs + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(testCase.matchedAddrs...))) + + unmatchedMetadata := testMetadata("lookup.example") + unmatchedMetadata.DestinationAddresses = testCase.unmatchedAddrs + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(testCase.unmatchedAddrs...))) + }) + } + t.Run("mixed resolved and deferred fields invert matches pre lookup", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("ruleset only deferred fields invert matches pre lookup", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + ruleSet := newLocalRuleSetForTest("dns-ruleset-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) +} + +func routeRuleForTest(build func(*abstractDefaultRule)) *DefaultRule { + rule := &DefaultRule{} + build(&rule.abstractDefaultRule) + return rule +} + +func dnsRuleForTest(build func(*abstractDefaultRule)) *DefaultDNSRule { + rule := &DefaultDNSRule{} + build(&rule.abstractDefaultRule) + return rule +} + +func headlessDefaultRule(t *testing.T, build func(*abstractDefaultRule)) *DefaultHeadlessRule { + t.Helper() + rule := &DefaultHeadlessRule{} + build(&rule.abstractDefaultRule) + return rule +} + +func headlessLogicalRule(mode string, invert bool, rules ...adapter.HeadlessRule) *LogicalHeadlessRule { + return &LogicalHeadlessRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: rules, + mode: mode, + invert: invert, + }, + } +} + +func newLocalRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *LocalRuleSet { + return &LocalRuleSet{ + tag: tag, + rules: rules, + } +} + +func newRemoteRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *RemoteRuleSet { + return &RemoteRuleSet{ + options: option.RuleSet{Tag: tag}, + rules: rules, + } +} + +func mustAdGuardRule(t *testing.T, content string) adapter.HeadlessRule { + t.Helper() + rules, err := adguard.ToOptions(strings.NewReader(content), slogger.NOP()) + require.NoError(t, err) + require.Len(t, rules, 1) + rule, err := NewHeadlessRule(context.Background(), rules[0]) + require.NoError(t, err) + return rule +} + +func testMetadata(domain string) adapter.InboundContext { + return adapter.InboundContext{ + Network: N.NetworkTCP, + Source: M.Socksaddr{ + Addr: netip.MustParseAddr("10.0.0.1"), + Port: 1000, + }, + Destination: M.Socksaddr{ + Fqdn: domain, + Port: 443, + }, + } +} + +func dnsResponseForTest(addresses ...netip.Addr) *mDNS.Msg { + response := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + } + for _, address := range addresses { + if address.Is4() { + response.Answer = append(response.Answer, &mDNS.A{ + Hdr: mDNS.RR_Header{ + Name: mDNS.Fqdn("lookup.example"), + Rrtype: mDNS.TypeA, + Class: mDNS.ClassINET, + Ttl: 60, + }, + A: net.IP(append([]byte(nil), address.AsSlice()...)), + }) + } else { + response.Answer = append(response.Answer, &mDNS.AAAA{ + Hdr: mDNS.RR_Header{ + Name: mDNS.Fqdn("lookup.example"), + Rrtype: mDNS.TypeAAAA, + Class: mDNS.ClassINET, + Ttl: 60, + }, + AAAA: net.IP(append([]byte(nil), address.AsSlice()...)), + }) + } + } + return response +} + +func addRuleSetItem(rule *abstractDefaultRule, item *RuleSetItem) { + rule.ruleSetItem = item + rule.allItems = append(rule.allItems, item) +} + +func addOtherItem(rule *abstractDefaultRule, item RuleItem) { + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) +} + +func addSourceAddressItem(t *testing.T, rule *abstractDefaultRule, cidrs []string) { + t.Helper() + item, err := NewIPCIDRItem(true, cidrs) + require.NoError(t, err) + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationAddressItem(t *testing.T, rule *abstractDefaultRule, domains []string, suffixes []string) { + t.Helper() + item, err := NewDomainItem(domains, suffixes) + require.NoError(t, err) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationKeywordItem(rule *abstractDefaultRule, keywords []string) { + item := NewDomainKeywordItem(keywords) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationRegexItem(t *testing.T, rule *abstractDefaultRule, regexes []string) { + t.Helper() + item, err := NewDomainRegexItem(regexes) + require.NoError(t, err) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationIPCIDRItem(t *testing.T, rule *abstractDefaultRule, cidrs []string) { + t.Helper() + item, err := NewIPCIDRItem(false, cidrs) + require.NoError(t, err) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationIPIsPrivateItem(rule *abstractDefaultRule) { + item := NewIPIsPrivateItem(false) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationIPAcceptAnyItem(rule *abstractDefaultRule) { + item := NewIPAcceptAnyItem() + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addSourcePortItem(rule *abstractDefaultRule, ports []uint16) { + item := NewPortItem(true, ports) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addSourcePortRangeItem(t *testing.T, rule *abstractDefaultRule, ranges []string) { + t.Helper() + item, err := NewPortRangeItem(true, ranges) + require.NoError(t, err) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationPortItem(rule *abstractDefaultRule, ports []uint16) { + item := NewPortItem(false, ports) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationPortRangeItem(t *testing.T, rule *abstractDefaultRule, ranges []string) { + t.Helper() + item, err := NewPortRangeItem(false, ranges) + require.NoError(t, err) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) +} diff --git a/route/rule/rule_set_update_validation_test.go b/route/rule/rule_set_update_validation_test.go new file mode 100644 index 00000000..0583d7bb --- /dev/null +++ b/route/rule/rule_set_update_validation_test.go @@ -0,0 +1,111 @@ +package rule + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + "github.com/stretchr/testify/require" +) + +type fakeDNSRuleSetUpdateValidator struct { + validate func(tag string, metadata adapter.RuleSetMetadata) error +} + +func (v *fakeDNSRuleSetUpdateValidator) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.RuleSetMetadata) error { + if v.validate == nil { + return nil + } + return v.validate(tag, metadata) +} + +func TestLocalRuleSetReloadRulesRejectsInvalidUpdateBeforeCommit(t *testing.T) { + t.Parallel() + + var callbackCount atomic.Int32 + ctx := service.ContextWith[adapter.DNSRuleSetUpdateValidator](context.Background(), &fakeDNSRuleSetUpdateValidator{ + validate: func(tag string, metadata adapter.RuleSetMetadata) error { + require.Equal(t, "dynamic-set", tag) + if metadata.ContainsDNSQueryTypeRule { + return E.New("dns conflict") + } + return nil + }, + }) + ruleSet := &LocalRuleSet{ + ctx: ctx, + tag: "dynamic-set", + fileFormat: C.RuleSetFormatSource, + } + _ = ruleSet.callbacks.PushBack(func(adapter.RuleSet) { + callbackCount.Add(1) + }) + + err := ruleSet.reloadRules([]option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }}) + require.NoError(t, err) + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) + + err = ruleSet.reloadRules([]option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(1)}, + }, + }}) + require.ErrorContains(t, err, "dns conflict") + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) +} + +func TestRemoteRuleSetLoadBytesRejectsInvalidUpdateBeforeCommit(t *testing.T) { + t.Parallel() + + var callbackCount atomic.Int32 + ctx := service.ContextWith[adapter.DNSRuleSetUpdateValidator](context.Background(), &fakeDNSRuleSetUpdateValidator{ + validate: func(tag string, metadata adapter.RuleSetMetadata) error { + require.Equal(t, "dynamic-set", tag) + if metadata.ContainsDNSQueryTypeRule { + return E.New("dns conflict") + } + return nil + }, + }) + ruleSet := &RemoteRuleSet{ + ctx: ctx, + options: option.RuleSet{ + Tag: "dynamic-set", + Format: C.RuleSetFormatSource, + }, + callbacks: list.List[adapter.RuleSetUpdateCallback]{}, + } + _ = ruleSet.callbacks.PushBack(func(adapter.RuleSet) { + callbackCount.Add(1) + }) + + err := ruleSet.loadBytes([]byte(`{"version":4,"rules":[{"domain":["example.com"]}]}`)) + require.NoError(t, err) + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) + + err = ruleSet.loadBytes([]byte(`{"version":4,"rules":[{"query_type":["A"]}]}`)) + require.ErrorContains(t, err, "dns conflict") + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) +} diff --git a/route/rule_conds.go b/route/rule_conds.go new file mode 100644 index 00000000..2c629029 --- /dev/null +++ b/route/rule_conds.go @@ -0,0 +1,62 @@ +package route + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool { + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if cond(rule.DefaultOptions) { + return true + } + case C.RuleTypeLogical: + if hasRule(rule.LogicalOptions.Rules, cond) { + return true + } + } + } + return false +} + +func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bool) bool { + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if cond(rule.DefaultOptions) { + return true + } + case C.RuleTypeLogical: + if hasDNSRule(rule.LogicalOptions.Rules, cond) { + return true + } + } + } + return false +} + +func isProcessRule(rule option.DefaultRule) bool { + return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.PackageNameRegex) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 +} + +func isProcessDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.PackageNameRegex) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 +} + +func isNeighborRule(rule option.DefaultRule) bool { + return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0 +} + +func isNeighborDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0 +} + +func isWIFIRule(rule option.DefaultRule) bool { + return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 +} + +func isWIFIDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 +} diff --git a/service/acme/service.go b/service/acme/service.go new file mode 100644 index 00000000..8286a197 --- /dev/null +++ b/service/acme/service.go @@ -0,0 +1,411 @@ +//go:build with_acme + +package acme + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "net" + "net/http" + "net/url" + "reflect" + "strings" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/common/dialer" + boxtls "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/ntp" + + "github.com/caddyserver/certmagic" + "github.com/caddyserver/zerossl" + "github.com/libdns/alidns" + "github.com/libdns/cloudflare" + "github.com/libdns/libdns" + "github.com/mholt/acmez/v3/acme" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func RegisterCertificateProvider(registry *certificate.Registry) { + certificate.Register[option.ACMECertificateProviderOptions](registry, C.TypeACME, NewCertificateProvider) +} + +var ( + _ adapter.CertificateProviderService = (*Service)(nil) + _ adapter.ACMECertificateProvider = (*Service)(nil) +) + +type Service struct { + certificate.Adapter + ctx context.Context + config *certmagic.Config + cache *certmagic.Cache + domain []string + nextProtos []string +} + +func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMECertificateProviderOptions) (adapter.CertificateProviderService, error) { + if len(options.Domain) == 0 { + return nil, E.New("missing domain") + } + var acmeServer string + switch options.Provider { + case "", "letsencrypt": + acmeServer = certmagic.LetsEncryptProductionCA + case "zerossl": + acmeServer = certmagic.ZeroSSLProductionCA + default: + if !strings.HasPrefix(options.Provider, "https://") { + return nil, E.New("unsupported ACME provider: ", options.Provider) + } + acmeServer = options.Provider + } + if acmeServer == certmagic.ZeroSSLProductionCA && + (options.ExternalAccount == nil || options.ExternalAccount.KeyID == "") && + strings.TrimSpace(options.Email) == "" && + strings.TrimSpace(options.AccountKey) == "" { + return nil, E.New("email is required to use the ZeroSSL ACME endpoint without external_account or account_key") + } + + var storage certmagic.Storage + if options.DataDirectory != "" { + storage = &certmagic.FileStorage{Path: options.DataDirectory} + } else { + storage = certmagic.Default.Storage + } + + zapLogger := zap.New(zapcore.NewCore( + zapcore.NewConsoleEncoder(boxtls.ACMEEncoderConfig()), + &boxtls.ACMELogWriter{Logger: logger}, + zap.DebugLevel, + )) + + config := &certmagic.Config{ + DefaultServerName: options.DefaultServerName, + Storage: storage, + Logger: zapLogger, + } + if options.KeyType != "" { + var keyType certmagic.KeyType + switch options.KeyType { + case option.ACMEKeyTypeED25519: + keyType = certmagic.ED25519 + case option.ACMEKeyTypeP256: + keyType = certmagic.P256 + case option.ACMEKeyTypeP384: + keyType = certmagic.P384 + case option.ACMEKeyTypeRSA2048: + keyType = certmagic.RSA2048 + case option.ACMEKeyTypeRSA4096: + keyType = certmagic.RSA4096 + default: + return nil, E.New("unsupported ACME key type: ", options.KeyType) + } + config.KeySource = certmagic.StandardKeyGenerator{KeyType: keyType} + } + + acmeIssuer := certmagic.ACMEIssuer{ + CA: acmeServer, + Email: options.Email, + AccountKeyPEM: options.AccountKey, + Agreed: true, + DisableHTTPChallenge: options.DisableHTTPChallenge, + DisableTLSALPNChallenge: options.DisableTLSALPNChallenge, + AltHTTPPort: int(options.AlternativeHTTPPort), + AltTLSALPNPort: int(options.AlternativeTLSPort), + Logger: zapLogger, + } + acmeHTTPClient, err := newACMEHTTPClient(ctx, options.Detour) + if err != nil { + return nil, err + } + dnsSolver, err := newDNSSolver(options.DNS01Challenge, zapLogger, acmeHTTPClient) + if err != nil { + return nil, err + } + if dnsSolver != nil { + acmeIssuer.DNS01Solver = dnsSolver + } + if options.ExternalAccount != nil && options.ExternalAccount.KeyID != "" { + acmeIssuer.ExternalAccount = (*acme.EAB)(options.ExternalAccount) + } + if acmeServer == certmagic.ZeroSSLProductionCA { + acmeIssuer.NewAccountFunc = func(ctx context.Context, acmeIssuer *certmagic.ACMEIssuer, account acme.Account) (acme.Account, error) { + if acmeIssuer.ExternalAccount != nil { + return account, nil + } + var err error + acmeIssuer.ExternalAccount, account, err = createZeroSSLExternalAccountBinding(ctx, acmeIssuer, account, acmeHTTPClient) + return account, err + } + } + + certmagicIssuer := certmagic.NewACMEIssuer(config, acmeIssuer) + httpClientField := reflect.ValueOf(certmagicIssuer).Elem().FieldByName("httpClient") + if !httpClientField.IsValid() || !httpClientField.CanAddr() { + return nil, E.New("certmagic ACME issuer HTTP client field is unavailable") + } + reflect.NewAt(httpClientField.Type(), unsafe.Pointer(httpClientField.UnsafeAddr())).Elem().Set(reflect.ValueOf(acmeHTTPClient)) + config.Issuers = []certmagic.Issuer{certmagicIssuer} + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { + return config, nil + }, + Logger: zapLogger, + }) + config = certmagic.New(cache, *config) + + var nextProtos []string + if !acmeIssuer.DisableTLSALPNChallenge && acmeIssuer.DNS01Solver == nil { + nextProtos = []string{C.ACMETLS1Protocol} + } + return &Service{ + Adapter: certificate.NewAdapter(C.TypeACME, tag), + ctx: ctx, + config: config, + cache: cache, + domain: options.Domain, + nextProtos: nextProtos, + }, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return s.config.ManageAsync(s.ctx, s.domain) +} + +func (s *Service) Close() error { + if s.cache != nil { + s.cache.Stop() + } + return nil +} + +func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return s.config.GetCertificate(hello) +} + +func (s *Service) GetACMENextProtos() []string { + return s.nextProtos +} + +func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger *zap.Logger, httpClient *http.Client) (*certmagic.DNS01Solver, error) { + if dnsOptions == nil || dnsOptions.Provider == "" { + return nil, nil + } + if dnsOptions.TTL < 0 { + return nil, E.New("invalid ACME DNS01 ttl: ", dnsOptions.TTL) + } + if dnsOptions.PropagationDelay < 0 { + return nil, E.New("invalid ACME DNS01 propagation_delay: ", dnsOptions.PropagationDelay) + } + if dnsOptions.PropagationTimeout < -1 { + return nil, E.New("invalid ACME DNS01 propagation_timeout: ", dnsOptions.PropagationTimeout) + } + solver := &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + TTL: time.Duration(dnsOptions.TTL), + PropagationDelay: time.Duration(dnsOptions.PropagationDelay), + PropagationTimeout: time.Duration(dnsOptions.PropagationTimeout), + Resolvers: dnsOptions.Resolvers, + OverrideDomain: dnsOptions.OverrideDomain, + Logger: logger.Named("dns_manager"), + }, + } + switch dnsOptions.Provider { + case C.DNSProviderAliDNS: + solver.DNSProvider = &alidns.Provider{ + CredentialInfo: alidns.CredentialInfo{ + AccessKeyID: dnsOptions.AliDNSOptions.AccessKeyID, + AccessKeySecret: dnsOptions.AliDNSOptions.AccessKeySecret, + RegionID: dnsOptions.AliDNSOptions.RegionID, + SecurityToken: dnsOptions.AliDNSOptions.SecurityToken, + }, + } + case C.DNSProviderCloudflare: + solver.DNSProvider = &cloudflare.Provider{ + APIToken: dnsOptions.CloudflareOptions.APIToken, + ZoneToken: dnsOptions.CloudflareOptions.ZoneToken, + HTTPClient: httpClient, + } + case C.DNSProviderACMEDNS: + solver.DNSProvider = &acmeDNSProvider{ + username: dnsOptions.ACMEDNSOptions.Username, + password: dnsOptions.ACMEDNSOptions.Password, + subdomain: dnsOptions.ACMEDNSOptions.Subdomain, + serverURL: dnsOptions.ACMEDNSOptions.ServerURL, + httpClient: httpClient, + } + default: + return nil, E.New("unsupported ACME DNS01 provider type: ", dnsOptions.Provider) + } + return solver, nil +} + +func createZeroSSLExternalAccountBinding(ctx context.Context, acmeIssuer *certmagic.ACMEIssuer, account acme.Account, httpClient *http.Client) (*acme.EAB, acme.Account, error) { + email := strings.TrimSpace(acmeIssuer.Email) + if email == "" { + return nil, acme.Account{}, E.New("email is required to use the ZeroSSL ACME endpoint without external_account") + } + if len(account.Contact) == 0 { + account.Contact = []string{"mailto:" + email} + } + if acmeIssuer.CertObtainTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, acmeIssuer.CertObtainTimeout) + defer cancel() + } + + form := url.Values{"email": []string{email}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, zerossl.BaseURL+"/acme/eab-credentials-email", strings.NewReader(form.Encode())) + if err != nil { + return nil, account, E.Cause(err, "create ZeroSSL EAB request") + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("User-Agent", certmagic.UserAgent) + + response, err := httpClient.Do(request) + if err != nil { + return nil, account, E.Cause(err, "request ZeroSSL EAB") + } + defer response.Body.Close() + + var result struct { + Success bool `json:"success"` + Error struct { + Code int `json:"code"` + Type string `json:"type"` + } `json:"error"` + EABKID string `json:"eab_kid"` + EABHMACKey string `json:"eab_hmac_key"` + } + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + return nil, account, E.Cause(err, "decode ZeroSSL EAB response") + } + if response.StatusCode != http.StatusOK { + return nil, account, E.New("failed getting ZeroSSL EAB credentials: HTTP ", response.StatusCode) + } + if result.Error.Code != 0 { + return nil, account, E.New("failed getting ZeroSSL EAB credentials: ", result.Error.Type, " (code ", result.Error.Code, ")") + } + + acmeIssuer.Logger.Info("generated ZeroSSL EAB credentials", zap.String("key_id", result.EABKID)) + + return &acme.EAB{ + KeyID: result.EABKID, + MACKey: result.EABHMACKey, + }, account, nil +} + +func newACMEHTTPClient(ctx context.Context, detour string) (*http.Client, error) { + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: option.DialerOptions{ + Detour: detour, + }, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "create ACME provider dialer") + } + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + RootCAs: adapter.RootPoolFromContext(ctx), + Time: ntp.TimeFuncFromContext(ctx), + }, + // from certmagic defaults (acmeissuer.go) + TLSHandshakeTimeout: 30 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: 2 * time.Second, + ForceAttemptHTTP2: true, + }, + Timeout: certmagic.HTTPTimeout, + }, nil +} + +type acmeDNSProvider struct { + username string + password string + subdomain string + serverURL string + httpClient *http.Client +} + +type acmeDNSRecord struct { + resourceRecord libdns.RR +} + +func (r acmeDNSRecord) RR() libdns.RR { + return r.resourceRecord +} + +func (p *acmeDNSProvider) AppendRecords(ctx context.Context, _ string, records []libdns.Record) ([]libdns.Record, error) { + if p.username == "" { + return nil, E.New("ACME-DNS username cannot be empty") + } + if p.password == "" { + return nil, E.New("ACME-DNS password cannot be empty") + } + if p.subdomain == "" { + return nil, E.New("ACME-DNS subdomain cannot be empty") + } + if p.serverURL == "" { + return nil, E.New("ACME-DNS server_url cannot be empty") + } + appendedRecords := make([]libdns.Record, 0, len(records)) + for _, record := range records { + resourceRecord := record.RR() + if resourceRecord.Type != "TXT" { + return appendedRecords, E.New("ACME-DNS only supports adding TXT records") + } + requestBody, err := json.Marshal(map[string]string{ + "subdomain": p.subdomain, + "txt": resourceRecord.Data, + }) + if err != nil { + return appendedRecords, E.Cause(err, "marshal ACME-DNS update request") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.serverURL+"/update", bytes.NewReader(requestBody)) + if err != nil { + return appendedRecords, E.Cause(err, "create ACME-DNS update request") + } + request.Header.Set("X-Api-User", p.username) + request.Header.Set("X-Api-Key", p.password) + request.Header.Set("Content-Type", "application/json") + response, err := p.httpClient.Do(request) + if err != nil { + return appendedRecords, E.Cause(err, "update ACME-DNS record") + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + return appendedRecords, E.New("update ACME-DNS record: HTTP ", response.StatusCode) + } + appendedRecords = append(appendedRecords, acmeDNSRecord{resourceRecord: libdns.RR{ + Type: "TXT", + Name: resourceRecord.Name, + Data: resourceRecord.Data, + }}) + } + return appendedRecords, nil +} + +func (p *acmeDNSProvider) DeleteRecords(context.Context, string, []libdns.Record) ([]libdns.Record, error) { + return nil, nil +} diff --git a/service/acme/stub.go b/service/acme/stub.go new file mode 100644 index 00000000..43a58d64 --- /dev/null +++ b/service/acme/stub.go @@ -0,0 +1,3 @@ +//go:build !with_acme + +package acme diff --git a/service/ccm/credential.go b/service/ccm/credential.go new file mode 100644 index 00000000..695efc7a --- /dev/null +++ b/service/ccm/credential.go @@ -0,0 +1,139 @@ +package ccm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "os/user" + "path/filepath" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + oauth2ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + oauth2TokenURL = "https://console.anthropic.com/v1/oauth/token" + claudeAPIBaseURL = "https://api.anthropic.com" + tokenRefreshBufferMs = 60000 + anthropicBetaOAuthValue = "oauth-2025-04-20" +) + +func getRealUser() (*user.User, error) { + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + sudoUserInfo, err := user.Lookup(sudoUser) + if err == nil { + return sudoUserInfo, nil + } + } + return user.Current() +} + +func getDefaultCredentialsPath() (string, error) { + if configDir := os.Getenv("CLAUDE_CONFIG_DIR"); configDir != "" { + return filepath.Join(configDir, ".credentials.json"), nil + } + userInfo, err := getRealUser() + if err != nil { + return "", err + } + return filepath.Join(userInfo.HomeDir, ".claude", ".credentials.json"), nil +} + +func readCredentialsFromFile(path string) (*oauthCredentials, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var credentialsContainer struct { + ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"` + } + err = json.Unmarshal(data, &credentialsContainer) + if err != nil { + return nil, err + } + if credentialsContainer.ClaudeAIAuth == nil { + return nil, E.New("claudeAiOauth field not found in credentials") + } + return credentialsContainer.ClaudeAIAuth, nil +} + +func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error { + data, err := json.MarshalIndent(map[string]any{ + "claudeAiOauth": oauthCredentials, + }, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +type oauthCredentials struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Scopes []string `json:"scopes,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` + IsMax bool `json:"isMax,omitempty"` +} + +func (c *oauthCredentials) needsRefresh() bool { + if c.ExpiresAt == 0 { + return false + } + return time.Now().UnixMilli() >= c.ExpiresAt-tokenRefreshBufferMs +} + +func refreshToken(httpClient *http.Client, credentials *oauthCredentials) (*oauthCredentials, error) { + if credentials.RefreshToken == "" { + return nil, E.New("refresh token is empty") + } + + requestBody, err := json.Marshal(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": credentials.RefreshToken, + "client_id": oauth2ClientID, + }) + if err != nil { + return nil, E.Cause(err, "marshal request") + } + + request, err := http.NewRequest("POST", oauth2TokenURL, bytes.NewReader(requestBody)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + + response, err := httpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + return nil, E.New("refresh failed: ", response.Status, " ", string(body)) + } + + var tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + err = json.NewDecoder(response.Body).Decode(&tokenResponse) + if err != nil { + return nil, E.Cause(err, "decode response") + } + + newCredentials := *credentials + newCredentials.AccessToken = tokenResponse.AccessToken + if tokenResponse.RefreshToken != "" { + newCredentials.RefreshToken = tokenResponse.RefreshToken + } + newCredentials.ExpiresAt = time.Now().UnixMilli() + int64(tokenResponse.ExpiresIn)*1000 + + return &newCredentials, nil +} diff --git a/service/ccm/credential_darwin.go b/service/ccm/credential_darwin.go new file mode 100644 index 00000000..24047b85 --- /dev/null +++ b/service/ccm/credential_darwin.go @@ -0,0 +1,116 @@ +//go:build darwin && cgo + +package ccm + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/keybase/go-keychain" +) + +func getKeychainServiceName() string { + configDirectory := os.Getenv("CLAUDE_CONFIG_DIR") + if configDirectory == "" { + return "Claude Code-credentials" + } + + userInfo, err := getRealUser() + if err != nil { + return "Claude Code-credentials" + } + defaultConfigDirectory := filepath.Join(userInfo.HomeDir, ".claude") + if configDirectory == defaultConfigDirectory { + return "Claude Code-credentials" + } + + hash := sha256.Sum256([]byte(configDirectory)) + return "Claude Code-credentials-" + hex.EncodeToString(hash[:])[:8] +} + +func platformReadCredentials(customPath string) (*oauthCredentials, error) { + if customPath != "" { + return readCredentialsFromFile(customPath) + } + + userInfo, err := getRealUser() + if err == nil { + query := keychain.NewItem() + query.SetSecClass(keychain.SecClassGenericPassword) + query.SetService(getKeychainServiceName()) + query.SetAccount(userInfo.Username) + query.SetMatchLimit(keychain.MatchLimitOne) + query.SetReturnData(true) + + results, err := keychain.QueryItem(query) + if err == nil && len(results) == 1 { + var container struct { + ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"` + } + unmarshalErr := json.Unmarshal(results[0].Data, &container) + if unmarshalErr == nil && container.ClaudeAIAuth != nil { + return container.ClaudeAIAuth, nil + } + } + if err != nil && err != keychain.ErrorItemNotFound { + return nil, E.Cause(err, "query keychain") + } + } + + defaultPath, err := getDefaultCredentialsPath() + if err != nil { + return nil, err + } + return readCredentialsFromFile(defaultPath) +} + +func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error { + if customPath != "" { + return writeCredentialsToFile(oauthCredentials, customPath) + } + + userInfo, err := getRealUser() + if err == nil { + data, err := json.Marshal(map[string]any{"claudeAiOauth": oauthCredentials}) + if err == nil { + serviceName := getKeychainServiceName() + item := keychain.NewItem() + item.SetSecClass(keychain.SecClassGenericPassword) + item.SetService(serviceName) + item.SetAccount(userInfo.Username) + item.SetData(data) + item.SetAccessible(keychain.AccessibleWhenUnlocked) + + err = keychain.AddItem(item) + if err == nil { + return nil + } + + if err == keychain.ErrorDuplicateItem { + query := keychain.NewItem() + query.SetSecClass(keychain.SecClassGenericPassword) + query.SetService(serviceName) + query.SetAccount(userInfo.Username) + + updateItem := keychain.NewItem() + updateItem.SetData(data) + + updateErr := keychain.UpdateItem(query, updateItem) + if updateErr == nil { + return nil + } + } + } + } + + defaultPath, err := getDefaultCredentialsPath() + if err != nil { + return err + } + return writeCredentialsToFile(oauthCredentials, defaultPath) +} diff --git a/service/ccm/credential_other.go b/service/ccm/credential_other.go new file mode 100644 index 00000000..11888b50 --- /dev/null +++ b/service/ccm/credential_other.go @@ -0,0 +1,25 @@ +//go:build !darwin + +package ccm + +func platformReadCredentials(customPath string) (*oauthCredentials, error) { + if customPath == "" { + var err error + customPath, err = getDefaultCredentialsPath() + if err != nil { + return nil, err + } + } + return readCredentialsFromFile(customPath) +} + +func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error { + if customPath == "" { + var err error + customPath, err = getDefaultCredentialsPath() + if err != nil { + return err + } + } + return writeCredentialsToFile(oauthCredentials, customPath) +} diff --git a/service/ccm/service.go b/service/ccm/service.go new file mode 100644 index 00000000..34c38824 --- /dev/null +++ b/service/ccm/service.go @@ -0,0 +1,597 @@ +package ccm + +import ( + "bytes" + "context" + stdTLS "crypto/tls" + "encoding/json" + "errors" + "io" + "mime" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + aTLS "github.com/sagernet/sing/common/tls" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/go-chi/chi/v5" + "golang.org/x/net/http2" +) + +const ( + contextWindowStandard = 200000 + contextWindowPremium = 1000000 + premiumContextThreshold = 200000 +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.CCMServiceOptions](registry, C.TypeCCM, NewService) +} + +type errorResponse struct { + Type string `json:"type"` + Error errorDetails `json:"error"` + RequestID string `json:"request_id,omitempty"` +} + +type errorDetails struct { + Type string `json:"type"` + Message string `json:"message"` +} + +func writeJSONError(w http.ResponseWriter, r *http.Request, statusCode int, errorType string, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + json.NewEncoder(w).Encode(errorResponse{ + Type: "error", + Error: errorDetails{ + Type: errorType, + Message: message, + }, + RequestID: r.Header.Get("Request-Id"), + }) +} + +func isHopByHopHeader(header string) bool { + switch strings.ToLower(header) { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host": + return true + default: + return false + } +} + +const ( + weeklyWindowSeconds = 604800 + weeklyWindowMinutes = weeklyWindowSeconds / 60 +) + +func parseInt64Header(headers http.Header, headerName string) (int64, bool) { + headerValue := strings.TrimSpace(headers.Get(headerName)) + if headerValue == "" { + return 0, false + } + parsedValue, parseError := strconv.ParseInt(headerValue, 10, 64) + if parseError != nil { + return 0, false + } + return parsedValue, true +} + +func extractWeeklyCycleHint(headers http.Header) *WeeklyCycleHint { + resetAtUnix, hasResetAt := parseInt64Header(headers, "anthropic-ratelimit-unified-7d-reset") + if !hasResetAt || resetAtUnix <= 0 { + return nil + } + + return &WeeklyCycleHint{ + WindowMinutes: weeklyWindowMinutes, + ResetAt: time.Unix(resetAtUnix, 0).UTC(), + } +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + credentialPath string + credentials *oauthCredentials + users []option.CCMUser + httpClient *http.Client + httpHeaders http.Header + listener *listener.Listener + tlsConfig tls.ServerConfig + httpServer *http.Server + userManager *UserManager + accessMutex sync.RWMutex + usageTracker *AggregatedUsage + trackingGroup sync.WaitGroup + shuttingDown bool +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) { + serviceDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: option.DialerOptions{ + Detour: options.Detour, + }, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "create dialer") + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSClientConfig: &stdTLS.Config{ + RootCAs: adapter.RootPoolFromContext(ctx), + Time: ntp.TimeFuncFromContext(ctx), + }, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + }, + } + + userManager := &UserManager{ + tokenMap: make(map[string]string), + } + + var usageTracker *AggregatedUsage + if options.UsagesPath != "" { + usageTracker = &AggregatedUsage{ + LastUpdated: time.Now(), + Combinations: make([]CostCombination, 0), + filePath: options.UsagesPath, + logger: logger, + } + } + + service := &Service{ + Adapter: boxService.NewAdapter(C.TypeCCM, tag), + ctx: ctx, + logger: logger, + credentialPath: options.CredentialPath, + users: options.Users, + httpClient: httpClient, + httpHeaders: options.Headers.Build(), + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + userManager: userManager, + usageTracker: usageTracker, + } + + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + service.tlsConfig = tlsConfig + } + + return service, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + + s.userManager.UpdateUsers(s.users) + + credentials, err := platformReadCredentials(s.credentialPath) + if err != nil { + return E.Cause(err, "read credentials") + } + s.credentials = credentials + + if s.usageTracker != nil { + err = s.usageTracker.Load() + if err != nil { + s.logger.Warn("load usage statistics: ", err) + } + } + + router := chi.NewRouter() + router.Mount("/", s) + + s.httpServer = &http.Server{Handler: router} + + if s.tlsConfig != nil { + err = s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + + tcpListener, err := s.listener.ListenTCP() + if err != nil { + return err + } + + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) + } + + go func() { + serveErr := s.httpServer.Serve(tcpListener) + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + s.logger.Error("serve error: ", serveErr) + } + }() + + return nil +} + +func (s *Service) getAccessToken() (string, error) { + s.accessMutex.RLock() + if !s.credentials.needsRefresh() { + token := s.credentials.AccessToken + s.accessMutex.RUnlock() + return token, nil + } + s.accessMutex.RUnlock() + + s.accessMutex.Lock() + defer s.accessMutex.Unlock() + + if !s.credentials.needsRefresh() { + return s.credentials.AccessToken, nil + } + + newCredentials, err := refreshToken(s.httpClient, s.credentials) + if err != nil { + return "", err + } + + s.credentials = newCredentials + + err = platformWriteCredentials(newCredentials, s.credentialPath) + if err != nil { + s.logger.Warn("persist refreshed token: ", err) + } + + return newCredentials.AccessToken, nil +} + +func detectContextWindow(betaHeader string, totalInputTokens int64) int { + if totalInputTokens > premiumContextThreshold { + features := strings.Split(betaHeader, ",") + for _, feature := range features { + if strings.HasPrefix(strings.TrimSpace(feature), "context-1m") { + return contextWindowPremium + } + } + } + return contextWindowStandard +} + +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/v1/") { + writeJSONError(w, r, http.StatusNotFound, "not_found_error", "Not found") + return + } + + var username string + if len(s.users) > 0 { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": missing Authorization header") + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "missing api key") + return + } + clientToken := strings.TrimPrefix(authHeader, "Bearer ") + if clientToken == authHeader { + s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": invalid Authorization format") + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key format") + return + } + var ok bool + username, ok = s.userManager.Authenticate(clientToken) + if !ok { + s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": unknown key: ", clientToken) + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key") + return + } + } + + var requestModel string + var messagesCount int + + if s.usageTracker != nil && r.Body != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err == nil { + var request struct { + Model string `json:"model"` + Messages []anthropic.MessageParam `json:"messages"` + } + err := json.Unmarshal(bodyBytes, &request) + if err == nil { + requestModel = request.Model + messagesCount = len(request.Messages) + } + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + accessToken, err := s.getAccessToken() + if err != nil { + s.logger.Error("get access token: ", err) + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "Authentication failed") + return + } + + proxyURL := claudeAPIBaseURL + r.URL.RequestURI() + proxyRequest, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body) + if err != nil { + s.logger.Error("create proxy request: ", err) + writeJSONError(w, r, http.StatusInternalServerError, "api_error", "Internal server error") + return + } + + for key, values := range r.Header { + if !isHopByHopHeader(key) && key != "Authorization" { + proxyRequest.Header[key] = values + } + } + + serviceOverridesAcceptEncoding := len(s.httpHeaders.Values("Accept-Encoding")) > 0 + if s.usageTracker != nil && !serviceOverridesAcceptEncoding { + // Strip Accept-Encoding so Go Transport adds it automatically + // and transparently decompresses the response for correct usage counting. + proxyRequest.Header.Del("Accept-Encoding") + } + + anthropicBetaHeader := proxyRequest.Header.Get("anthropic-beta") + if anthropicBetaHeader != "" { + proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue+","+anthropicBetaHeader) + } else { + proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue) + } + + for key, values := range s.httpHeaders { + proxyRequest.Header.Del(key) + proxyRequest.Header[key] = values + } + + proxyRequest.Header.Set("Authorization", "Bearer "+accessToken) + + response, err := s.httpClient.Do(proxyRequest) + if err != nil { + writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error()) + return + } + defer response.Body.Close() + + for key, values := range response.Header { + if !isHopByHopHeader(key) { + w.Header()[key] = values + } + } + w.WriteHeader(response.StatusCode) + + if s.usageTracker != nil && response.StatusCode == http.StatusOK { + s.handleResponseWithTracking(w, response, requestModel, anthropicBetaHeader, messagesCount, username) + } else { + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err == nil && mediaType != "text/event-stream" { + _, _ = io.Copy(w, response.Body) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + s.logger.Error("streaming not supported") + return + } + buffer := make([]byte, buf.BufferSize) + for { + n, err := response.Body.Read(buffer) + if n > 0 { + _, writeError := w.Write(buffer[:n]) + if writeError != nil { + s.logger.Error("write streaming response: ", writeError) + return + } + flusher.Flush() + } + if err != nil { + return + } + } + } +} + +func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, response *http.Response, requestModel string, anthropicBetaHeader string, messagesCount int, username string) { + weeklyCycleHint := extractWeeklyCycleHint(response.Header) + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + isStreaming := err == nil && mediaType == "text/event-stream" + + if !isStreaming { + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + s.logger.Error("read response body: ", err) + return + } + + var message anthropic.Message + var usage anthropic.Usage + var responseModel string + err = json.Unmarshal(bodyBytes, &message) + if err == nil { + responseModel = string(message.Model) + usage = message.Usage + } + if responseModel == "" { + responseModel = requestModel + } + + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + if responseModel != "" { + totalInputTokens := usage.InputTokens + usage.CacheCreationInputTokens + usage.CacheReadInputTokens + contextWindow := detectContextWindow(anthropicBetaHeader, totalInputTokens) + s.usageTracker.AddUsageWithCycleHint( + responseModel, + contextWindow, + messagesCount, + usage.InputTokens, + usage.OutputTokens, + usage.CacheReadInputTokens, + usage.CacheCreationInputTokens, + usage.CacheCreation.Ephemeral5mInputTokens, + usage.CacheCreation.Ephemeral1hInputTokens, + username, + time.Now(), + weeklyCycleHint, + ) + } + } + + _, _ = writer.Write(bodyBytes) + return + } + + flusher, ok := writer.(http.Flusher) + if !ok { + s.logger.Error("streaming not supported") + return + } + + var accumulatedUsage anthropic.Usage + var responseModel string + buffer := make([]byte, buf.BufferSize) + var leftover []byte + + for { + n, err := response.Body.Read(buffer) + if n > 0 { + data := append(leftover, buffer[:n]...) + lines := bytes.Split(data, []byte("\n")) + + if err == nil { + leftover = lines[len(lines)-1] + lines = lines[:len(lines)-1] + } else { + leftover = nil + } + + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + + if bytes.HasPrefix(line, []byte("data: ")) { + eventData := bytes.TrimPrefix(line, []byte("data: ")) + if bytes.Equal(eventData, []byte("[DONE]")) { + continue + } + + var event anthropic.MessageStreamEventUnion + err := json.Unmarshal(eventData, &event) + if err != nil { + continue + } + switch event.Type { + case "message_start": + messageStart := event.AsMessageStart() + if messageStart.Message.Model != "" { + responseModel = string(messageStart.Message.Model) + } + if messageStart.Message.Usage.InputTokens > 0 { + accumulatedUsage.InputTokens = messageStart.Message.Usage.InputTokens + accumulatedUsage.CacheReadInputTokens = messageStart.Message.Usage.CacheReadInputTokens + accumulatedUsage.CacheCreationInputTokens = messageStart.Message.Usage.CacheCreationInputTokens + accumulatedUsage.CacheCreation.Ephemeral5mInputTokens = messageStart.Message.Usage.CacheCreation.Ephemeral5mInputTokens + accumulatedUsage.CacheCreation.Ephemeral1hInputTokens = messageStart.Message.Usage.CacheCreation.Ephemeral1hInputTokens + } + case "message_delta": + messageDelta := event.AsMessageDelta() + if messageDelta.Usage.OutputTokens > 0 { + accumulatedUsage.OutputTokens = messageDelta.Usage.OutputTokens + } + } + } + } + + _, writeError := writer.Write(buffer[:n]) + if writeError != nil { + s.logger.Error("write streaming response: ", writeError) + return + } + flusher.Flush() + } + + if err != nil { + if responseModel == "" { + responseModel = requestModel + } + + if accumulatedUsage.InputTokens > 0 || accumulatedUsage.OutputTokens > 0 { + if responseModel != "" { + totalInputTokens := accumulatedUsage.InputTokens + accumulatedUsage.CacheCreationInputTokens + accumulatedUsage.CacheReadInputTokens + contextWindow := detectContextWindow(anthropicBetaHeader, totalInputTokens) + s.usageTracker.AddUsageWithCycleHint( + responseModel, + contextWindow, + messagesCount, + accumulatedUsage.InputTokens, + accumulatedUsage.OutputTokens, + accumulatedUsage.CacheReadInputTokens, + accumulatedUsage.CacheCreationInputTokens, + accumulatedUsage.CacheCreation.Ephemeral5mInputTokens, + accumulatedUsage.CacheCreation.Ephemeral1hInputTokens, + username, + time.Now(), + weeklyCycleHint, + ) + } + } + return + } + } +} + +func (s *Service) Close() error { + err := common.Close( + common.PtrOrNil(s.httpServer), + common.PtrOrNil(s.listener), + s.tlsConfig, + ) + + if s.usageTracker != nil { + s.usageTracker.cancelPendingSave() + saveErr := s.usageTracker.Save() + if saveErr != nil { + s.logger.Error("save usage statistics: ", saveErr) + } + } + + return err +} diff --git a/service/ccm/service_usage.go b/service/ccm/service_usage.go new file mode 100644 index 00000000..36e9ee65 --- /dev/null +++ b/service/ccm/service_usage.go @@ -0,0 +1,706 @@ +package ccm + +import ( + "encoding/json" + "fmt" + "math" + "os" + "regexp" + "sync" + "time" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" +) + +type UsageStats struct { + RequestCount int `json:"request_count"` + MessagesCount int `json:"messages_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheCreation5MinuteInputTokens int64 `json:"cache_creation_5m_input_tokens,omitempty"` + CacheCreation1HourInputTokens int64 `json:"cache_creation_1h_input_tokens,omitempty"` +} + +type CostCombination struct { + Model string `json:"model"` + ContextWindow int `json:"context_window"` + WeekStartUnix int64 `json:"week_start_unix,omitempty"` + Total UsageStats `json:"total"` + ByUser map[string]UsageStats `json:"by_user"` +} + +type AggregatedUsage struct { + LastUpdated time.Time `json:"last_updated"` + Combinations []CostCombination `json:"combinations"` + mutex sync.Mutex + filePath string + logger log.ContextLogger + lastSaveTime time.Time + pendingSave bool + saveTimer *time.Timer + saveMutex sync.Mutex +} + +type UsageStatsJSON struct { + RequestCount int `json:"request_count"` + MessagesCount int `json:"messages_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheCreation5MinuteInputTokens int64 `json:"cache_creation_5m_input_tokens,omitempty"` + CacheCreation1HourInputTokens int64 `json:"cache_creation_1h_input_tokens,omitempty"` + CostUSD float64 `json:"cost_usd"` +} + +type CostCombinationJSON struct { + Model string `json:"model"` + ContextWindow int `json:"context_window"` + WeekStartUnix int64 `json:"week_start_unix,omitempty"` + Total UsageStatsJSON `json:"total"` + ByUser map[string]UsageStatsJSON `json:"by_user"` +} + +type CostsSummaryJSON struct { + TotalUSD float64 `json:"total_usd"` + ByUser map[string]float64 `json:"by_user"` + ByWeek map[string]float64 `json:"by_week,omitempty"` + ByUserAndWeek map[string]map[string]float64 `json:"by_user_and_week,omitempty"` +} + +type AggregatedUsageJSON struct { + LastUpdated time.Time `json:"last_updated"` + Costs CostsSummaryJSON `json:"costs"` + Combinations []CostCombinationJSON `json:"combinations"` +} + +type WeeklyCycleHint struct { + WindowMinutes int64 + ResetAt time.Time +} + +type ModelPricing struct { + InputPrice float64 + OutputPrice float64 + CacheReadPrice float64 + CacheWritePrice5Minute float64 + CacheWritePrice1Hour float64 +} + +type modelFamily struct { + pattern *regexp.Regexp + standardPricing ModelPricing + premiumPricing *ModelPricing +} + +var ( + opus46StandardPricing = ModelPricing{ + InputPrice: 5.0, + OutputPrice: 25.0, + CacheReadPrice: 0.5, + CacheWritePrice5Minute: 6.25, + CacheWritePrice1Hour: 10.0, + } + + opus46PremiumPricing = ModelPricing{ + InputPrice: 10.0, + OutputPrice: 37.5, + CacheReadPrice: 1.0, + CacheWritePrice5Minute: 12.5, + CacheWritePrice1Hour: 20.0, + } + + opus45Pricing = ModelPricing{ + InputPrice: 5.0, + OutputPrice: 25.0, + CacheReadPrice: 0.5, + CacheWritePrice5Minute: 6.25, + CacheWritePrice1Hour: 10.0, + } + + opus4Pricing = ModelPricing{ + InputPrice: 15.0, + OutputPrice: 75.0, + CacheReadPrice: 1.5, + CacheWritePrice5Minute: 18.75, + CacheWritePrice1Hour: 30.0, + } + + sonnet46StandardPricing = ModelPricing{ + InputPrice: 3.0, + OutputPrice: 15.0, + CacheReadPrice: 0.3, + CacheWritePrice5Minute: 3.75, + CacheWritePrice1Hour: 6.0, + } + + sonnet46PremiumPricing = ModelPricing{ + InputPrice: 6.0, + OutputPrice: 22.5, + CacheReadPrice: 0.6, + CacheWritePrice5Minute: 7.5, + CacheWritePrice1Hour: 12.0, + } + + sonnet45StandardPricing = ModelPricing{ + InputPrice: 3.0, + OutputPrice: 15.0, + CacheReadPrice: 0.3, + CacheWritePrice5Minute: 3.75, + CacheWritePrice1Hour: 6.0, + } + + sonnet45PremiumPricing = ModelPricing{ + InputPrice: 6.0, + OutputPrice: 22.5, + CacheReadPrice: 0.6, + CacheWritePrice5Minute: 7.5, + CacheWritePrice1Hour: 12.0, + } + + sonnet4StandardPricing = ModelPricing{ + InputPrice: 3.0, + OutputPrice: 15.0, + CacheReadPrice: 0.3, + CacheWritePrice5Minute: 3.75, + CacheWritePrice1Hour: 6.0, + } + + sonnet4PremiumPricing = ModelPricing{ + InputPrice: 6.0, + OutputPrice: 22.5, + CacheReadPrice: 0.6, + CacheWritePrice5Minute: 7.5, + CacheWritePrice1Hour: 12.0, + } + + sonnet37Pricing = ModelPricing{ + InputPrice: 3.0, + OutputPrice: 15.0, + CacheReadPrice: 0.3, + CacheWritePrice5Minute: 3.75, + CacheWritePrice1Hour: 6.0, + } + + sonnet35Pricing = ModelPricing{ + InputPrice: 3.0, + OutputPrice: 15.0, + CacheReadPrice: 0.3, + CacheWritePrice5Minute: 3.75, + CacheWritePrice1Hour: 6.0, + } + + haiku45Pricing = ModelPricing{ + InputPrice: 1.0, + OutputPrice: 5.0, + CacheReadPrice: 0.1, + CacheWritePrice5Minute: 1.25, + CacheWritePrice1Hour: 2.0, + } + + haiku4Pricing = ModelPricing{ + InputPrice: 1.0, + OutputPrice: 5.0, + CacheReadPrice: 0.1, + CacheWritePrice5Minute: 1.25, + CacheWritePrice1Hour: 2.0, + } + + haiku35Pricing = ModelPricing{ + InputPrice: 0.8, + OutputPrice: 4.0, + CacheReadPrice: 0.08, + CacheWritePrice5Minute: 1.0, + CacheWritePrice1Hour: 1.6, + } + + haiku3Pricing = ModelPricing{ + InputPrice: 0.25, + OutputPrice: 1.25, + CacheReadPrice: 0.03, + CacheWritePrice5Minute: 0.3, + CacheWritePrice1Hour: 0.5, + } + + opus3Pricing = ModelPricing{ + InputPrice: 15.0, + OutputPrice: 75.0, + CacheReadPrice: 1.5, + CacheWritePrice5Minute: 18.75, + CacheWritePrice1Hour: 30.0, + } + + modelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^claude-opus-4-6(?:-|$)`), + standardPricing: opus46StandardPricing, + premiumPricing: &opus46PremiumPricing, + }, + { + pattern: regexp.MustCompile(`^claude-opus-4-5(?:-|$)`), + standardPricing: opus45Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-(?:opus-4(?:-|$)|4-opus-)`), + standardPricing: opus4Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-(?:opus-3(?:-|$)|3-opus-)`), + standardPricing: opus3Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-(?:sonnet-4-6(?:-|$)|4-6-sonnet-)`), + standardPricing: sonnet46StandardPricing, + premiumPricing: &sonnet46PremiumPricing, + }, + { + pattern: regexp.MustCompile(`^claude-(?:sonnet-4-5(?:-|$)|4-5-sonnet-)`), + standardPricing: sonnet45StandardPricing, + premiumPricing: &sonnet45PremiumPricing, + }, + { + pattern: regexp.MustCompile(`^claude-(?:sonnet-4(?:-|$)|4-sonnet-)`), + standardPricing: sonnet4StandardPricing, + premiumPricing: &sonnet4PremiumPricing, + }, + { + pattern: regexp.MustCompile(`^claude-3-7-sonnet(?:-|$)`), + standardPricing: sonnet37Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-3-5-sonnet(?:-|$)`), + standardPricing: sonnet35Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-(?:haiku-4-5(?:-|$)|4-5-haiku-)`), + standardPricing: haiku45Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-haiku-4(?:-|$)`), + standardPricing: haiku4Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-3-5-haiku(?:-|$)`), + standardPricing: haiku35Pricing, + premiumPricing: nil, + }, + { + pattern: regexp.MustCompile(`^claude-3-haiku(?:-|$)`), + standardPricing: haiku3Pricing, + premiumPricing: nil, + }, + } +) + +func getPricing(model string, contextWindow int) ModelPricing { + isPremium := contextWindow >= contextWindowPremium + + for _, family := range modelFamilies { + if family.pattern.MatchString(model) { + if isPremium && family.premiumPricing != nil { + return *family.premiumPricing + } + return family.standardPricing + } + } + + return sonnet4StandardPricing +} + +func calculateCost(stats UsageStats, model string, contextWindow int) float64 { + pricing := getPricing(model, contextWindow) + + cacheCreationCost := 0.0 + if stats.CacheCreation5MinuteInputTokens > 0 || stats.CacheCreation1HourInputTokens > 0 { + cacheCreationCost = float64(stats.CacheCreation5MinuteInputTokens)*pricing.CacheWritePrice5Minute + + float64(stats.CacheCreation1HourInputTokens)*pricing.CacheWritePrice1Hour + } else { + // Backward compatibility for usage files generated before TTL split tracking. + cacheCreationCost = float64(stats.CacheCreationInputTokens) * pricing.CacheWritePrice5Minute + } + + cost := (float64(stats.InputTokens)*pricing.InputPrice + + float64(stats.OutputTokens)*pricing.OutputPrice + + float64(stats.CacheReadInputTokens)*pricing.CacheReadPrice + + cacheCreationCost) / 1_000_000 + + return math.Round(cost*100) / 100 +} + +func roundCost(cost float64) float64 { + return math.Round(cost*100) / 100 +} + +func normalizeCombinations(combinations []CostCombination) { + for index := range combinations { + if combinations[index].ByUser == nil { + combinations[index].ByUser = make(map[string]UsageStats) + } + } +} + +func addUsageToCombinations( + combinations *[]CostCombination, + model string, + contextWindow int, + weekStartUnix int64, + messagesCount int, + inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64, + user string, +) { + var matchedCombination *CostCombination + for index := range *combinations { + combination := &(*combinations)[index] + if combination.Model == model && combination.ContextWindow == contextWindow && combination.WeekStartUnix == weekStartUnix { + matchedCombination = combination + break + } + } + + if matchedCombination == nil { + newCombination := CostCombination{ + Model: model, + ContextWindow: contextWindow, + WeekStartUnix: weekStartUnix, + Total: UsageStats{}, + ByUser: make(map[string]UsageStats), + } + *combinations = append(*combinations, newCombination) + matchedCombination = &(*combinations)[len(*combinations)-1] + } + + if cacheCreationTokens == 0 { + cacheCreationTokens = cacheCreation5MinuteTokens + cacheCreation1HourTokens + } + + matchedCombination.Total.RequestCount++ + matchedCombination.Total.MessagesCount += messagesCount + matchedCombination.Total.InputTokens += inputTokens + matchedCombination.Total.OutputTokens += outputTokens + matchedCombination.Total.CacheReadInputTokens += cacheReadTokens + matchedCombination.Total.CacheCreationInputTokens += cacheCreationTokens + matchedCombination.Total.CacheCreation5MinuteInputTokens += cacheCreation5MinuteTokens + matchedCombination.Total.CacheCreation1HourInputTokens += cacheCreation1HourTokens + + if user != "" { + userStats := matchedCombination.ByUser[user] + userStats.RequestCount++ + userStats.MessagesCount += messagesCount + userStats.InputTokens += inputTokens + userStats.OutputTokens += outputTokens + userStats.CacheReadInputTokens += cacheReadTokens + userStats.CacheCreationInputTokens += cacheCreationTokens + userStats.CacheCreation5MinuteInputTokens += cacheCreation5MinuteTokens + userStats.CacheCreation1HourInputTokens += cacheCreation1HourTokens + matchedCombination.ByUser[user] = userStats + } +} + +func buildCombinationJSON(combinations []CostCombination, aggregateUserCosts map[string]float64) ([]CostCombinationJSON, float64) { + result := make([]CostCombinationJSON, len(combinations)) + var totalCost float64 + + for index, combination := range combinations { + combinationTotalCost := calculateCost(combination.Total, combination.Model, combination.ContextWindow) + totalCost += combinationTotalCost + + combinationJSON := CostCombinationJSON{ + Model: combination.Model, + ContextWindow: combination.ContextWindow, + WeekStartUnix: combination.WeekStartUnix, + Total: UsageStatsJSON{ + RequestCount: combination.Total.RequestCount, + MessagesCount: combination.Total.MessagesCount, + InputTokens: combination.Total.InputTokens, + OutputTokens: combination.Total.OutputTokens, + CacheReadInputTokens: combination.Total.CacheReadInputTokens, + CacheCreationInputTokens: combination.Total.CacheCreationInputTokens, + CacheCreation5MinuteInputTokens: combination.Total.CacheCreation5MinuteInputTokens, + CacheCreation1HourInputTokens: combination.Total.CacheCreation1HourInputTokens, + CostUSD: combinationTotalCost, + }, + ByUser: make(map[string]UsageStatsJSON), + } + + for user, userStats := range combination.ByUser { + userCost := calculateCost(userStats, combination.Model, combination.ContextWindow) + if aggregateUserCosts != nil { + aggregateUserCosts[user] += userCost + } + + combinationJSON.ByUser[user] = UsageStatsJSON{ + RequestCount: userStats.RequestCount, + MessagesCount: userStats.MessagesCount, + InputTokens: userStats.InputTokens, + OutputTokens: userStats.OutputTokens, + CacheReadInputTokens: userStats.CacheReadInputTokens, + CacheCreationInputTokens: userStats.CacheCreationInputTokens, + CacheCreation5MinuteInputTokens: userStats.CacheCreation5MinuteInputTokens, + CacheCreation1HourInputTokens: userStats.CacheCreation1HourInputTokens, + CostUSD: userCost, + } + } + + result[index] = combinationJSON + } + + return result, roundCost(totalCost) +} + +func formatUTCOffsetLabel(timestamp time.Time) string { + _, offsetSeconds := timestamp.Zone() + sign := "+" + if offsetSeconds < 0 { + sign = "-" + offsetSeconds = -offsetSeconds + } + offsetHours := offsetSeconds / 3600 + offsetMinutes := (offsetSeconds % 3600) / 60 + if offsetMinutes == 0 { + return fmt.Sprintf("UTC%s%d", sign, offsetHours) + } + return fmt.Sprintf("UTC%s%d:%02d", sign, offsetHours, offsetMinutes) +} + +func formatWeekStartKey(cycleStartAt time.Time) string { + localCycleStart := cycleStartAt.In(time.Local) + return fmt.Sprintf("%s %s", localCycleStart.Format("2006-01-02 15:04:05"), formatUTCOffsetLabel(localCycleStart)) +} + +func buildByWeekCost(combinations []CostCombination) map[string]float64 { + byWeek := make(map[string]float64) + for _, combination := range combinations { + if combination.WeekStartUnix <= 0 { + continue + } + weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() + weekKey := formatWeekStartKey(weekStartAt) + byWeek[weekKey] += calculateCost(combination.Total, combination.Model, combination.ContextWindow) + } + for weekKey, weekCost := range byWeek { + byWeek[weekKey] = roundCost(weekCost) + } + return byWeek +} + +func buildByUserAndWeekCost(combinations []CostCombination) map[string]map[string]float64 { + byUserAndWeek := make(map[string]map[string]float64) + for _, combination := range combinations { + if combination.WeekStartUnix <= 0 { + continue + } + weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() + weekKey := formatWeekStartKey(weekStartAt) + for user, userStats := range combination.ByUser { + userWeeks, exists := byUserAndWeek[user] + if !exists { + userWeeks = make(map[string]float64) + byUserAndWeek[user] = userWeeks + } + userWeeks[weekKey] += calculateCost(userStats, combination.Model, combination.ContextWindow) + } + } + for _, weekCosts := range byUserAndWeek { + for weekKey, cost := range weekCosts { + weekCosts[weekKey] = roundCost(cost) + } + } + return byUserAndWeek +} + +func deriveWeekStartUnix(cycleHint *WeeklyCycleHint) int64 { + if cycleHint == nil || cycleHint.WindowMinutes <= 0 || cycleHint.ResetAt.IsZero() { + return 0 + } + windowDuration := time.Duration(cycleHint.WindowMinutes) * time.Minute + return cycleHint.ResetAt.UTC().Add(-windowDuration).Unix() +} + +func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON { + u.mutex.Lock() + defer u.mutex.Unlock() + + result := &AggregatedUsageJSON{ + LastUpdated: u.LastUpdated, + Costs: CostsSummaryJSON{ + TotalUSD: 0, + ByUser: make(map[string]float64), + ByWeek: make(map[string]float64), + }, + } + + globalCombinationsJSON, totalCost := buildCombinationJSON(u.Combinations, result.Costs.ByUser) + result.Combinations = globalCombinationsJSON + result.Costs.TotalUSD = totalCost + result.Costs.ByWeek = buildByWeekCost(u.Combinations) + + if len(result.Costs.ByWeek) == 0 { + result.Costs.ByWeek = nil + } + + result.Costs.ByUserAndWeek = buildByUserAndWeekCost(u.Combinations) + if len(result.Costs.ByUserAndWeek) == 0 { + result.Costs.ByUserAndWeek = nil + } + + for user, cost := range result.Costs.ByUser { + result.Costs.ByUser[user] = roundCost(cost) + } + + return result +} + +func (u *AggregatedUsage) Load() error { + u.mutex.Lock() + defer u.mutex.Unlock() + + u.LastUpdated = time.Time{} + u.Combinations = nil + + data, err := os.ReadFile(u.filePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var temp struct { + LastUpdated time.Time `json:"last_updated"` + Combinations []CostCombination `json:"combinations"` + } + + err = json.Unmarshal(data, &temp) + if err != nil { + return err + } + + u.LastUpdated = temp.LastUpdated + u.Combinations = temp.Combinations + normalizeCombinations(u.Combinations) + + return nil +} + +func (u *AggregatedUsage) Save() error { + jsonData := u.ToJSON() + + data, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return err + } + + tmpFile := u.filePath + ".tmp" + err = os.WriteFile(tmpFile, data, 0o644) + if err != nil { + return err + } + defer os.Remove(tmpFile) + err = os.Rename(tmpFile, u.filePath) + if err == nil { + u.saveMutex.Lock() + u.lastSaveTime = time.Now() + u.saveMutex.Unlock() + } + return err +} + +func (u *AggregatedUsage) AddUsage( + model string, + contextWindow int, + messagesCount int, + inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64, + user string, +) error { + return u.AddUsageWithCycleHint(model, contextWindow, messagesCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens, user, time.Now(), nil) +} + +func (u *AggregatedUsage) AddUsageWithCycleHint( + model string, + contextWindow int, + messagesCount int, + inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64, + user string, + observedAt time.Time, + cycleHint *WeeklyCycleHint, +) error { + if model == "" { + return E.New("model cannot be empty") + } + if contextWindow <= 0 { + return E.New("contextWindow must be positive") + } + if observedAt.IsZero() { + observedAt = time.Now() + } + + u.mutex.Lock() + defer u.mutex.Unlock() + + u.LastUpdated = observedAt + weekStartUnix := deriveWeekStartUnix(cycleHint) + + addUsageToCombinations(&u.Combinations, model, contextWindow, weekStartUnix, messagesCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens, user) + + go u.scheduleSave() + + return nil +} + +func (u *AggregatedUsage) scheduleSave() { + const saveInterval = time.Minute + + u.saveMutex.Lock() + defer u.saveMutex.Unlock() + + timeSinceLastSave := time.Since(u.lastSaveTime) + + if timeSinceLastSave >= saveInterval { + go u.saveAsync() + return + } + + if u.pendingSave { + return + } + + u.pendingSave = true + remainingTime := saveInterval - timeSinceLastSave + + u.saveTimer = time.AfterFunc(remainingTime, func() { + u.saveMutex.Lock() + u.pendingSave = false + u.saveMutex.Unlock() + u.saveAsync() + }) +} + +func (u *AggregatedUsage) saveAsync() { + err := u.Save() + if err != nil { + if u.logger != nil { + u.logger.Error("save usage statistics: ", err) + } + } +} + +func (u *AggregatedUsage) cancelPendingSave() { + u.saveMutex.Lock() + defer u.saveMutex.Unlock() + + if u.saveTimer != nil { + u.saveTimer.Stop() + u.saveTimer = nil + } + u.pendingSave = false +} diff --git a/service/ccm/service_user.go b/service/ccm/service_user.go new file mode 100644 index 00000000..94637ed8 --- /dev/null +++ b/service/ccm/service_user.go @@ -0,0 +1,29 @@ +package ccm + +import ( + "sync" + + "github.com/sagernet/sing-box/option" +) + +type UserManager struct { + accessMutex sync.RWMutex + tokenMap map[string]string +} + +func (m *UserManager) UpdateUsers(users []option.CCMUser) { + m.accessMutex.Lock() + defer m.accessMutex.Unlock() + tokenMap := make(map[string]string, len(users)) + for _, user := range users { + tokenMap[user.Token] = user.Name + } + m.tokenMap = tokenMap +} + +func (m *UserManager) Authenticate(token string) (string, bool) { + m.accessMutex.RLock() + username, found := m.tokenMap[token] + m.accessMutex.RUnlock() + return username, found +} diff --git a/service/derp/service.go b/service/derp/service.go new file mode 100644 index 00000000..02dac60b --- /dev/null +++ b/service/derp/service.go @@ -0,0 +1,529 @@ +//go:build with_gvisor + +package derp + +import ( + "bufio" + "context" + stdTLS "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + boxScale "github.com/sagernet/sing-box/protocol/tailscale" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" + "github.com/sagernet/tailscale/client/local" + "github.com/sagernet/tailscale/derp" + "github.com/sagernet/tailscale/derp/derphttp" + "github.com/sagernet/tailscale/derp/derpserver" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/net/wsconn" + "github.com/sagernet/tailscale/tsweb" + "github.com/sagernet/tailscale/types/key" + + "github.com/coder/websocket" + "github.com/go-chi/render" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +func Register(registry *boxService.Registry) { + boxService.Register[option.DERPServiceOptions](registry, C.TypeDERP, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger logger.ContextLogger + listener *listener.Listener + stunListener *listener.Listener + tlsConfig tls.ServerConfig + server *derpserver.Server + configPath string + verifyClientEndpoint []string + verifyClientURL []*option.DERPVerifyClientURLOptions + home string + meshKey string + meshKeyPath string + meshWith []*option.DERPMeshOptions +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPServiceOptions) (adapter.Service, error) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, E.New("TLS is required for DERP server") + } + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + + var configPath string + if options.ConfigPath != "" { + configPath = filemanager.BasePath(ctx, os.ExpandEnv(options.ConfigPath)) + } else { + return nil, E.New("missing config_path") + } + + if options.MeshPSK != "" { + err = checkMeshKey(options.MeshPSK) + if err != nil { + return nil, E.Cause(err, "invalid mesh_psk") + } + } + + var stunListener *listener.Listener + if options.STUN != nil && options.STUN.Enabled { + if options.STUN.Listen == nil { + options.STUN.Listen = (*badoption.Addr)(common.Ptr(netip.IPv6Unspecified())) + } + if options.STUN.ListenPort == 0 { + options.STUN.ListenPort = 3478 + } + stunListener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkUDP}, + Listen: options.STUN.ListenOptions, + }) + } + + return &Service{ + Adapter: boxService.NewAdapter(C.TypeDERP, tag), + ctx: ctx, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + stunListener: stunListener, + tlsConfig: tlsConfig, + configPath: configPath, + verifyClientEndpoint: options.VerifyClientEndpoint, + verifyClientURL: options.VerifyClientURL, + home: options.Home, + meshKey: options.MeshPSK, + meshKeyPath: options.MeshPSKFile, + meshWith: options.MeshWith, + }, nil +} + +func (d *Service) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateStart: + config, err := readDERPConfig(filemanager.BasePath(d.ctx, d.configPath)) + if err != nil { + return err + } + + server := derpserver.New(config.PrivateKey, func(format string, args ...any) { + d.logger.Debug(fmt.Sprintf(format, args...)) + }) + + if len(d.verifyClientURL) > 0 { + var httpClients []*http.Client + var urls []string + for index, options := range d.verifyClientURL { + verifyDialer, createErr := dialer.NewWithOptions(dialer.Options{ + Context: d.ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain(), + NewDialer: true, + }) + if createErr != nil { + return E.Cause(createErr, "verify_client_url[", index, "]") + } + httpClients = append(httpClients, &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSClientConfig: &stdTLS.Config{ + RootCAs: adapter.RootPoolFromContext(d.ctx), + Time: ntp.TimeFuncFromContext(d.ctx), + }, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return verifyDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + }, + }) + urls = append(urls, options.URL) + } + server.SetVerifyClientHTTPClient(httpClients) + server.SetVerifyClientURL(urls) + } + + if d.meshKey != "" { + server.SetMeshKey(d.meshKey) + } else if d.meshKeyPath != "" { + var meshKeyContent []byte + meshKeyContent, err = os.ReadFile(d.meshKeyPath) + if err != nil { + return err + } + err = checkMeshKey(string(meshKeyContent)) + if err != nil { + return E.Cause(err, "invalid mesh_psk_path file") + } + server.SetMeshKey(string(meshKeyContent)) + } + d.server = server + + derpMux := http.NewServeMux() + derpHandler := derpserver.Handler(server) + derpHandler = addWebSocketSupport(server, derpHandler) + derpMux.Handle("/derp", derpHandler) + + homeHandler, ok := getHomeHandler(d.home) + if !ok { + return E.New("invalid home value: ", d.home) + } + + derpMux.HandleFunc("/derp/probe", derpserver.ProbeHandler) + derpMux.HandleFunc("/derp/latency-check", derpserver.ProbeHandler) + derpMux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS(d.ctx))) + derpMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tsweb.AddBrowserHeaders(w) + homeHandler.ServeHTTP(w, r) + })) + derpMux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tsweb.AddBrowserHeaders(w) + io.WriteString(w, "User-agent: *\nDisallow: /\n") + })) + derpMux.Handle("/generate_204", http.HandlerFunc(derpserver.ServeNoContent)) + + err = d.tlsConfig.Start() + if err != nil { + return err + } + + tcpListener, err := d.listener.ListenTCP() + if err != nil { + return err + } + if len(d.tlsConfig.NextProtos()) == 0 { + d.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"}) + } else if !common.Contains(d.tlsConfig.NextProtos(), http2.NextProtoTLS) { + d.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, d.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, d.tlsConfig) + httpServer := &http.Server{ + Handler: h2c.NewHandler(derpMux, &http2.Server{}), + } + go httpServer.Serve(tcpListener) + + if d.stunListener != nil { + stunConn, err := d.stunListener.ListenUDP() + if err != nil { + return err + } + go d.loopSTUNPacket(stunConn.(*net.UDPConn)) + } + case adapter.StartStatePostStart: + if len(d.verifyClientEndpoint) > 0 { + var endpoints []*local.Client + endpointManager := service.FromContext[adapter.EndpointManager](d.ctx) + for _, endpointTag := range d.verifyClientEndpoint { + endpoint, loaded := endpointManager.Get(endpointTag) + if !loaded { + return E.New("verify_client_endpoint: endpoint not found: ", endpointTag) + } + tsEndpoint, isTailscale := endpoint.(*boxScale.Endpoint) + if !isTailscale { + return E.New("verify_client_endpoint: endpoint is not Tailscale: ", endpointTag) + } + localClient, err := tsEndpoint.Server().LocalClient() + if err != nil { + return err + } + endpoints = append(endpoints, localClient) + } + d.server.SetVerifyClientLocalClient(endpoints) + } + if len(d.meshWith) > 0 { + if !d.server.HasMeshKey() { + return E.New("missing mesh psk") + } + for _, options := range d.meshWith { + err := d.startMeshWithHost(d.server, options) + if err != nil { + return err + } + } + } + } + return nil +} + +func checkMeshKey(meshKey string) error { + checkRegex, err := regexp.Compile(`^[0-9a-f]{64}$`) + if err != nil { + return err + } + if !checkRegex.MatchString(meshKey) { + return E.New("key must contain exactly 64 hex digits") + } + return nil +} + +func (d *Service) startMeshWithHost(derpServer *derpserver.Server, server *option.DERPMeshOptions) error { + meshDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: d.ctx, + Options: server.DialerOptions, + RemoteIsDomain: server.ServerIsDomain(), + NewDialer: true, + }) + if err != nil { + return err + } + var hostname string + if server.Host != "" { + hostname = server.Host + } else { + hostname = server.Server + } + var stdConfig *tls.STDConfig + if server.TLS != nil && server.TLS.Enabled { + tlsConfig, err := tls.NewClient(d.ctx, d.logger, hostname, common.PtrValueOrDefault(server.TLS)) + if err != nil { + return err + } + stdConfig, err = tlsConfig.STDConfig() + if err != nil { + return err + } + } + logf := func(format string, args ...any) { + d.logger.Debug(F.ToString("mesh(", hostname, "): ", fmt.Sprintf(format, args...))) + } + var meshHost string + if server.ServerPort == 0 || server.ServerPort == 443 { + meshHost = hostname + } else { + meshHost = M.ParseSocksaddrHostPort(hostname, server.ServerPort).String() + } + var serverURL string + if stdConfig != nil { + serverURL = "https://" + meshHost + "/derp" + } else { + serverURL = "http://" + meshHost + "/derp" + } + meshClient, err := derphttp.NewClient(derpServer.PrivateKey(), serverURL, logf, netmon.NewStatic()) + if err != nil { + return err + } + meshClient.TLSConfig = stdConfig + meshClient.MeshKey = derpServer.MeshKey() + meshClient.WatchConnectionChanges = true + meshClient.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) { + return meshDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }) + add := func(m derp.PeerPresentMessage) { derpServer.AddPacketForwarder(m.Key, meshClient) } + remove := func(m derp.PeerGoneMessage) { derpServer.RemovePacketForwarder(m.Peer, meshClient) } + notifyError := func(err error) { d.logger.Error(err) } + go meshClient.RunWatchConnectionLoop(context.Background(), derpServer.PublicKey(), logf, add, remove, notifyError) + return nil +} + +func (d *Service) Close() error { + return common.Close( + common.PtrOrNil(d.listener), + d.tlsConfig, + ) +} + +var homePage = ` +

DERP

+

+ This is a Tailscale DERP server. +

+ +

+ It provides STUN, interactive connectivity establishment, and relaying of end-to-end encrypted traffic + for Tailscale clients. +

+ +

+ Documentation: +

+ +
    + +
  • About DERP
  • +
  • Protocol & Go docs
  • +
  • How to run a DERP server
  • + + + +` + +func getHomeHandler(val string) (_ http.Handler, ok bool) { + if val == "" { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(200) + w.Write([]byte(homePage)) + }), true + } + if val == "blank" { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(200) + }), true + } + if strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://") { + return http.RedirectHandler(val, http.StatusFound), true + } + return nil, false +} + +func addWebSocketSupport(s *derpserver.Server, base http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + up := strings.ToLower(r.Header.Get("Upgrade")) + + // Very early versions of Tailscale set "Upgrade: WebSocket" but didn't actually + // speak WebSockets (they still assumed DERP's binary framing). So to distinguish + // clients that actually want WebSockets, look for an explicit "derp" subprotocol. + if up != "websocket" || !strings.Contains(r.Header.Get("Sec-Websocket-Protocol"), "derp") { + base.ServeHTTP(w, r) + return + } + + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{"derp"}, + OriginPatterns: []string{"*"}, + // Disable compression because we transmit WireGuard messages that + // are not compressible. + // Additionally, Safari has a broken implementation of compression + // (see https://github.com/nhooyr/websocket/issues/218) that makes + // enabling it actively harmful. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return + } + defer c.Close(websocket.StatusInternalError, "closing") + if c.Subprotocol() != "derp" { + c.Close(websocket.StatusPolicyViolation, "client must speak the derp subprotocol") + return + } + wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary, r.RemoteAddr) + brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc)) + s.Accept(r.Context(), wc, brw, r.RemoteAddr) + }) +} + +func handleBootstrapDNS(ctx context.Context) http.HandlerFunc { + dnsRouter := service.FromContext[adapter.DNSRouter](ctx) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Connection", "close") + if queryDomain := r.URL.Query().Get("q"); queryDomain != "" { + addresses, err := dnsRouter.Lookup(ctx, queryDomain, adapter.DNSQueryOptions{}) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + render.JSON(w, r, render.M{ + queryDomain: addresses, + }) + return + } + w.Write([]byte("{}")) + } +} + +type derpConfig struct { + PrivateKey key.NodePrivate +} + +func readDERPConfig(path string) (*derpConfig, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return writeNewDERPConfig(path) + } + return nil, err + } + var config derpConfig + err = json.Unmarshal(content, &config) + if err != nil { + return nil, err + } + return &config, nil +} + +func writeNewDERPConfig(path string) (*derpConfig, error) { + newKey := key.NewNode() + err := os.MkdirAll(filepath.Dir(path), 0o777) + if err != nil { + return nil, err + } + config := derpConfig{ + PrivateKey: newKey, + } + content, err := json.Marshal(config) + if err != nil { + return nil, err + } + err = os.WriteFile(path, content, 0o644) + if err != nil { + return nil, err + } + return &config, nil +} + +func (d *Service) loopSTUNPacket(packetConn *net.UDPConn) { + buffer := make([]byte, 65535) + oob := make([]byte, 1024) + var ( + n int + oobN int + addrPort netip.AddrPort + err error + ) + for { + n, oobN, _, addrPort, err = packetConn.ReadMsgUDPAddrPort(buffer, oob) + if err != nil { + if E.IsClosedOrCanceled(err) { + return + } + time.Sleep(time.Second) + continue + } + if !stun.Is(buffer[:n]) { + continue + } + txid, err := stun.ParseBindingRequest(buffer[:n]) + if err != nil { + continue + } + packetConn.WriteMsgUDPAddrPort(stun.Response(txid, addrPort), oob[:oobN], addrPort) + } +} diff --git a/service/ocm/credential.go b/service/ocm/credential.go new file mode 100644 index 00000000..76651a8e --- /dev/null +++ b/service/ocm/credential.go @@ -0,0 +1,173 @@ +package ocm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "os/user" + "path/filepath" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + oauth2ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + oauth2TokenURL = "https://auth.openai.com/oauth/token" + openaiAPIBaseURL = "https://api.openai.com" + chatGPTBackendURL = "https://chatgpt.com/backend-api/codex" + tokenRefreshIntervalDays = 8 +) + +func getRealUser() (*user.User, error) { + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + sudoUserInfo, err := user.Lookup(sudoUser) + if err == nil { + return sudoUserInfo, nil + } + } + return user.Current() +} + +func getDefaultCredentialsPath() (string, error) { + if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { + return filepath.Join(codexHome, "auth.json"), nil + } + userInfo, err := getRealUser() + if err != nil { + return "", err + } + return filepath.Join(userInfo.HomeDir, ".codex", "auth.json"), nil +} + +func readCredentialsFromFile(path string) (*oauthCredentials, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var credentials oauthCredentials + err = json.Unmarshal(data, &credentials) + if err != nil { + return nil, err + } + return &credentials, nil +} + +func writeCredentialsToFile(credentials *oauthCredentials, path string) error { + data, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +type oauthCredentials struct { + APIKey string `json:"OPENAI_API_KEY,omitempty"` + Tokens *tokenData `json:"tokens,omitempty"` + LastRefresh *time.Time `json:"last_refresh,omitempty"` +} + +type tokenData struct { + IDToken string `json:"id_token,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id,omitempty"` +} + +func (c *oauthCredentials) isAPIKeyMode() bool { + return c.APIKey != "" +} + +func (c *oauthCredentials) getAccessToken() string { + if c.APIKey != "" { + return c.APIKey + } + if c.Tokens != nil { + return c.Tokens.AccessToken + } + return "" +} + +func (c *oauthCredentials) getAccountID() string { + if c.Tokens != nil { + return c.Tokens.AccountID + } + return "" +} + +func (c *oauthCredentials) needsRefresh() bool { + if c.APIKey != "" { + return false + } + if c.Tokens == nil || c.Tokens.RefreshToken == "" { + return false + } + if c.LastRefresh == nil { + return true + } + return time.Since(*c.LastRefresh) >= time.Duration(tokenRefreshIntervalDays)*24*time.Hour +} + +func refreshToken(httpClient *http.Client, credentials *oauthCredentials) (*oauthCredentials, error) { + if credentials.Tokens == nil || credentials.Tokens.RefreshToken == "" { + return nil, E.New("refresh token is empty") + } + + requestBody, err := json.Marshal(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": credentials.Tokens.RefreshToken, + "client_id": oauth2ClientID, + "scope": "openid profile email", + }) + if err != nil { + return nil, E.Cause(err, "marshal request") + } + + request, err := http.NewRequest("POST", oauth2TokenURL, bytes.NewReader(requestBody)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + + response, err := httpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + return nil, E.New("refresh failed: ", response.Status, " ", string(body)) + } + + var tokenResponse struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + err = json.NewDecoder(response.Body).Decode(&tokenResponse) + if err != nil { + return nil, E.Cause(err, "decode response") + } + + newCredentials := *credentials + if newCredentials.Tokens == nil { + newCredentials.Tokens = &tokenData{} + } + if tokenResponse.IDToken != "" { + newCredentials.Tokens.IDToken = tokenResponse.IDToken + } + if tokenResponse.AccessToken != "" { + newCredentials.Tokens.AccessToken = tokenResponse.AccessToken + } + if tokenResponse.RefreshToken != "" { + newCredentials.Tokens.RefreshToken = tokenResponse.RefreshToken + } + now := time.Now() + newCredentials.LastRefresh = &now + + return &newCredentials, nil +} diff --git a/service/ocm/credential_darwin.go b/service/ocm/credential_darwin.go new file mode 100644 index 00000000..f3da2a63 --- /dev/null +++ b/service/ocm/credential_darwin.go @@ -0,0 +1,25 @@ +//go:build darwin + +package ocm + +func platformReadCredentials(customPath string) (*oauthCredentials, error) { + if customPath == "" { + var err error + customPath, err = getDefaultCredentialsPath() + if err != nil { + return nil, err + } + } + return readCredentialsFromFile(customPath) +} + +func platformWriteCredentials(credentials *oauthCredentials, customPath string) error { + if customPath == "" { + var err error + customPath, err = getDefaultCredentialsPath() + if err != nil { + return err + } + } + return writeCredentialsToFile(credentials, customPath) +} diff --git a/service/ocm/credential_other.go b/service/ocm/credential_other.go new file mode 100644 index 00000000..22dfd033 --- /dev/null +++ b/service/ocm/credential_other.go @@ -0,0 +1,25 @@ +//go:build !darwin + +package ocm + +func platformReadCredentials(customPath string) (*oauthCredentials, error) { + if customPath == "" { + var err error + customPath, err = getDefaultCredentialsPath() + if err != nil { + return nil, err + } + } + return readCredentialsFromFile(customPath) +} + +func platformWriteCredentials(credentials *oauthCredentials, customPath string) error { + if customPath == "" { + var err error + customPath, err = getDefaultCredentialsPath() + if err != nil { + return err + } + } + return writeCredentialsToFile(credentials, customPath) +} diff --git a/service/ocm/service.go b/service/ocm/service.go new file mode 100644 index 00000000..8b66964a --- /dev/null +++ b/service/ocm/service.go @@ -0,0 +1,707 @@ +package ocm + +import ( + "bytes" + "context" + stdTLS "crypto/tls" + "encoding/json" + "errors" + "io" + "mime" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + aTLS "github.com/sagernet/sing/common/tls" + + "github.com/go-chi/chi/v5" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + "golang.org/x/net/http2" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.OCMServiceOptions](registry, C.TypeOCM, NewService) +} + +type errorResponse struct { + Error errorDetails `json:"error"` +} + +type errorDetails struct { + Type string `json:"type"` + Code string `json:"code,omitempty"` + Message string `json:"message"` +} + +func writeJSONError(w http.ResponseWriter, r *http.Request, statusCode int, errorType string, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + json.NewEncoder(w).Encode(errorResponse{ + Error: errorDetails{ + Type: errorType, + Message: message, + }, + }) +} + +func isHopByHopHeader(header string) bool { + switch strings.ToLower(header) { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host": + return true + default: + return false + } +} + +func normalizeRateLimitIdentifier(limitIdentifier string) string { + trimmedIdentifier := strings.TrimSpace(strings.ToLower(limitIdentifier)) + if trimmedIdentifier == "" { + return "" + } + return strings.ReplaceAll(trimmedIdentifier, "_", "-") +} + +func parseInt64Header(headers http.Header, headerName string) (int64, bool) { + headerValue := strings.TrimSpace(headers.Get(headerName)) + if headerValue == "" { + return 0, false + } + parsedValue, parseError := strconv.ParseInt(headerValue, 10, 64) + if parseError != nil { + return 0, false + } + return parsedValue, true +} + +func weeklyCycleHintForLimit(headers http.Header, limitIdentifier string) *WeeklyCycleHint { + normalizedLimitIdentifier := normalizeRateLimitIdentifier(limitIdentifier) + if normalizedLimitIdentifier == "" { + return nil + } + + windowHeader := "x-" + normalizedLimitIdentifier + "-secondary-window-minutes" + resetHeader := "x-" + normalizedLimitIdentifier + "-secondary-reset-at" + + windowMinutes, hasWindowMinutes := parseInt64Header(headers, windowHeader) + resetAtUnix, hasResetAt := parseInt64Header(headers, resetHeader) + if !hasWindowMinutes || !hasResetAt || windowMinutes <= 0 || resetAtUnix <= 0 { + return nil + } + + return &WeeklyCycleHint{ + WindowMinutes: windowMinutes, + ResetAt: time.Unix(resetAtUnix, 0).UTC(), + } +} + +func extractWeeklyCycleHint(headers http.Header) *WeeklyCycleHint { + activeLimitIdentifier := normalizeRateLimitIdentifier(headers.Get("x-codex-active-limit")) + if activeLimitIdentifier != "" { + if activeHint := weeklyCycleHintForLimit(headers, activeLimitIdentifier); activeHint != nil { + return activeHint + } + } + return weeklyCycleHintForLimit(headers, "codex") +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + credentialPath string + credentials *oauthCredentials + users []option.OCMUser + dialer N.Dialer + httpClient *http.Client + httpHeaders http.Header + listener *listener.Listener + tlsConfig tls.ServerConfig + httpServer *http.Server + userManager *UserManager + accessMutex sync.RWMutex + usageTracker *AggregatedUsage + webSocketMutex sync.Mutex + webSocketGroup sync.WaitGroup + webSocketConns map[*webSocketSession]struct{} + shuttingDown bool +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OCMServiceOptions) (adapter.Service, error) { + serviceDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: option.DialerOptions{ + Detour: options.Detour, + }, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "create dialer") + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSClientConfig: &stdTLS.Config{ + RootCAs: adapter.RootPoolFromContext(ctx), + Time: ntp.TimeFuncFromContext(ctx), + }, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + }, + } + + userManager := &UserManager{ + tokenMap: make(map[string]string), + } + + var usageTracker *AggregatedUsage + if options.UsagesPath != "" { + usageTracker = &AggregatedUsage{ + LastUpdated: time.Now(), + Combinations: make([]CostCombination, 0), + filePath: options.UsagesPath, + logger: logger, + } + } + + service := &Service{ + Adapter: boxService.NewAdapter(C.TypeOCM, tag), + ctx: ctx, + logger: logger, + credentialPath: options.CredentialPath, + users: options.Users, + dialer: serviceDialer, + httpClient: httpClient, + httpHeaders: options.Headers.Build(), + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + userManager: userManager, + usageTracker: usageTracker, + webSocketConns: make(map[*webSocketSession]struct{}), + } + + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + service.tlsConfig = tlsConfig + } + + return service, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + + s.userManager.UpdateUsers(s.users) + + credentials, err := platformReadCredentials(s.credentialPath) + if err != nil { + return E.Cause(err, "read credentials") + } + s.credentials = credentials + + if s.usageTracker != nil { + err = s.usageTracker.Load() + if err != nil { + s.logger.Warn("load usage statistics: ", err) + } + } + + router := chi.NewRouter() + router.Mount("/", s) + + s.httpServer = &http.Server{Handler: router} + + if s.tlsConfig != nil { + err = s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + + tcpListener, err := s.listener.ListenTCP() + if err != nil { + return err + } + + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) + } + + go func() { + serveErr := s.httpServer.Serve(tcpListener) + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + s.logger.Error("serve error: ", serveErr) + } + }() + + return nil +} + +func (s *Service) getAccessToken() (string, error) { + s.accessMutex.RLock() + if !s.credentials.needsRefresh() { + token := s.credentials.getAccessToken() + s.accessMutex.RUnlock() + return token, nil + } + s.accessMutex.RUnlock() + + s.accessMutex.Lock() + defer s.accessMutex.Unlock() + + if !s.credentials.needsRefresh() { + return s.credentials.getAccessToken(), nil + } + + newCredentials, err := refreshToken(s.httpClient, s.credentials) + if err != nil { + return "", err + } + + s.credentials = newCredentials + + err = platformWriteCredentials(newCredentials, s.credentialPath) + if err != nil { + s.logger.Warn("persist refreshed token: ", err) + } + + return newCredentials.getAccessToken(), nil +} + +func (s *Service) getAccountID() string { + s.accessMutex.RLock() + defer s.accessMutex.RUnlock() + return s.credentials.getAccountID() +} + +func (s *Service) isAPIKeyMode() bool { + s.accessMutex.RLock() + defer s.accessMutex.RUnlock() + return s.credentials.isAPIKeyMode() +} + +func (s *Service) getBaseURL() string { + if s.isAPIKeyMode() { + return openaiAPIBaseURL + } + return chatGPTBackendURL +} + +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if !strings.HasPrefix(path, "/v1/") { + writeJSONError(w, r, http.StatusNotFound, "invalid_request_error", "path must start with /v1/") + return + } + + var proxyPath string + if s.isAPIKeyMode() { + proxyPath = path + } else { + if path == "/v1/chat/completions" { + writeJSONError(w, r, http.StatusBadRequest, "invalid_request_error", + "chat completions endpoint is only available in API key mode") + return + } + proxyPath = strings.TrimPrefix(path, "/v1") + } + + var username string + if len(s.users) > 0 { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": missing Authorization header") + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "missing api key") + return + } + clientToken := strings.TrimPrefix(authHeader, "Bearer ") + if clientToken == authHeader { + s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": invalid Authorization format") + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key format") + return + } + var ok bool + username, ok = s.userManager.Authenticate(clientToken) + if !ok { + s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": unknown key: ", clientToken) + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key") + return + } + } + + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && strings.HasPrefix(path, "/v1/responses") { + s.handleWebSocket(w, r, proxyPath, username) + return + } + + var requestModel string + + if s.usageTracker != nil && r.Body != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err == nil { + var request struct { + Model string `json:"model"` + } + err := json.Unmarshal(bodyBytes, &request) + if err == nil { + requestModel = request.Model + } + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + accessToken, err := s.getAccessToken() + if err != nil { + s.logger.Error("get access token: ", err) + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "Authentication failed") + return + } + + proxyURL := s.getBaseURL() + proxyPath + if r.URL.RawQuery != "" { + proxyURL += "?" + r.URL.RawQuery + } + proxyRequest, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body) + if err != nil { + s.logger.Error("create proxy request: ", err) + writeJSONError(w, r, http.StatusInternalServerError, "api_error", "Internal server error") + return + } + + for key, values := range r.Header { + if !isHopByHopHeader(key) && key != "Authorization" { + proxyRequest.Header[key] = values + } + } + + for key, values := range s.httpHeaders { + proxyRequest.Header.Del(key) + proxyRequest.Header[key] = values + } + + proxyRequest.Header.Set("Authorization", "Bearer "+accessToken) + + if accountID := s.getAccountID(); accountID != "" { + proxyRequest.Header.Set("ChatGPT-Account-Id", accountID) + } + + response, err := s.httpClient.Do(proxyRequest) + if err != nil { + writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error()) + return + } + defer response.Body.Close() + + for key, values := range response.Header { + if !isHopByHopHeader(key) { + w.Header()[key] = values + } + } + w.WriteHeader(response.StatusCode) + + trackUsage := s.usageTracker != nil && response.StatusCode == http.StatusOK && + (path == "/v1/chat/completions" || strings.HasPrefix(path, "/v1/responses")) + if trackUsage { + s.handleResponseWithTracking(w, response, path, requestModel, username) + } else { + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err == nil && mediaType != "text/event-stream" { + _, _ = io.Copy(w, response.Body) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + s.logger.Error("streaming not supported") + return + } + buffer := make([]byte, buf.BufferSize) + for { + n, err := response.Body.Read(buffer) + if n > 0 { + _, writeError := w.Write(buffer[:n]) + if writeError != nil { + s.logger.Error("write streaming response: ", writeError) + return + } + flusher.Flush() + } + if err != nil { + return + } + } + } +} + +func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, response *http.Response, path string, requestModel string, username string) { + isChatCompletions := path == "/v1/chat/completions" + weeklyCycleHint := extractWeeklyCycleHint(response.Header) + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + isStreaming := err == nil && mediaType == "text/event-stream" + if !isStreaming && !isChatCompletions && response.Header.Get("Content-Type") == "" { + isStreaming = true + } + if !isStreaming { + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + s.logger.Error("read response body: ", err) + return + } + + var responseModel, serviceTier string + var inputTokens, outputTokens, cachedTokens int64 + + if isChatCompletions { + var chatCompletion openai.ChatCompletion + if json.Unmarshal(bodyBytes, &chatCompletion) == nil { + responseModel = chatCompletion.Model + serviceTier = string(chatCompletion.ServiceTier) + inputTokens = chatCompletion.Usage.PromptTokens + outputTokens = chatCompletion.Usage.CompletionTokens + cachedTokens = chatCompletion.Usage.PromptTokensDetails.CachedTokens + } + } else { + var responsesResponse responses.Response + if json.Unmarshal(bodyBytes, &responsesResponse) == nil { + responseModel = string(responsesResponse.Model) + serviceTier = string(responsesResponse.ServiceTier) + inputTokens = responsesResponse.Usage.InputTokens + outputTokens = responsesResponse.Usage.OutputTokens + cachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens + } + } + + if inputTokens > 0 || outputTokens > 0 { + if responseModel == "" { + responseModel = requestModel + } + if responseModel != "" { + contextWindow := detectContextWindow(responseModel, serviceTier, inputTokens) + s.usageTracker.AddUsageWithCycleHint( + responseModel, + contextWindow, + inputTokens, + outputTokens, + cachedTokens, + serviceTier, + username, + time.Now(), + weeklyCycleHint, + ) + } + } + + _, _ = writer.Write(bodyBytes) + return + } + + flusher, ok := writer.(http.Flusher) + if !ok { + s.logger.Error("streaming not supported") + return + } + + var inputTokens, outputTokens, cachedTokens int64 + var responseModel, serviceTier string + buffer := make([]byte, buf.BufferSize) + var leftover []byte + + for { + n, err := response.Body.Read(buffer) + if n > 0 { + data := append(leftover, buffer[:n]...) + lines := bytes.Split(data, []byte("\n")) + + if err == nil { + leftover = lines[len(lines)-1] + lines = lines[:len(lines)-1] + } else { + leftover = nil + } + + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + + if bytes.HasPrefix(line, []byte("data: ")) { + eventData := bytes.TrimPrefix(line, []byte("data: ")) + if bytes.Equal(eventData, []byte("[DONE]")) { + continue + } + + if isChatCompletions { + var chatChunk openai.ChatCompletionChunk + if json.Unmarshal(eventData, &chatChunk) == nil { + if chatChunk.Model != "" { + responseModel = chatChunk.Model + } + if chatChunk.ServiceTier != "" { + serviceTier = string(chatChunk.ServiceTier) + } + if chatChunk.Usage.PromptTokens > 0 { + inputTokens = chatChunk.Usage.PromptTokens + cachedTokens = chatChunk.Usage.PromptTokensDetails.CachedTokens + } + if chatChunk.Usage.CompletionTokens > 0 { + outputTokens = chatChunk.Usage.CompletionTokens + } + } + } else { + var streamEvent responses.ResponseStreamEventUnion + if json.Unmarshal(eventData, &streamEvent) == nil { + if streamEvent.Type == "response.completed" { + completedEvent := streamEvent.AsResponseCompleted() + if string(completedEvent.Response.Model) != "" { + responseModel = string(completedEvent.Response.Model) + } + if completedEvent.Response.ServiceTier != "" { + serviceTier = string(completedEvent.Response.ServiceTier) + } + if completedEvent.Response.Usage.InputTokens > 0 { + inputTokens = completedEvent.Response.Usage.InputTokens + cachedTokens = completedEvent.Response.Usage.InputTokensDetails.CachedTokens + } + if completedEvent.Response.Usage.OutputTokens > 0 { + outputTokens = completedEvent.Response.Usage.OutputTokens + } + } + } + } + } + } + + _, writeError := writer.Write(buffer[:n]) + if writeError != nil { + s.logger.Error("write streaming response: ", writeError) + return + } + flusher.Flush() + } + + if err != nil { + if responseModel == "" { + responseModel = requestModel + } + + if inputTokens > 0 || outputTokens > 0 { + if responseModel != "" { + contextWindow := detectContextWindow(responseModel, serviceTier, inputTokens) + s.usageTracker.AddUsageWithCycleHint( + responseModel, + contextWindow, + inputTokens, + outputTokens, + cachedTokens, + serviceTier, + username, + time.Now(), + weeklyCycleHint, + ) + } + } + return + } + } +} + +func (s *Service) Close() error { + webSocketSessions := s.startWebSocketShutdown() + + err := common.Close( + common.PtrOrNil(s.httpServer), + common.PtrOrNil(s.listener), + s.tlsConfig, + ) + for _, session := range webSocketSessions { + session.Close() + } + s.webSocketGroup.Wait() + + if s.usageTracker != nil { + s.usageTracker.cancelPendingSave() + saveErr := s.usageTracker.Save() + if saveErr != nil { + s.logger.Error("save usage statistics: ", saveErr) + } + } + + return err +} + +func (s *Service) registerWebSocketSession(session *webSocketSession) bool { + s.webSocketMutex.Lock() + defer s.webSocketMutex.Unlock() + + if s.shuttingDown { + return false + } + + s.webSocketConns[session] = struct{}{} + s.webSocketGroup.Add(1) + return true +} + +func (s *Service) unregisterWebSocketSession(session *webSocketSession) { + s.webSocketMutex.Lock() + _, loaded := s.webSocketConns[session] + if loaded { + delete(s.webSocketConns, session) + } + s.webSocketMutex.Unlock() + + if loaded { + s.webSocketGroup.Done() + } +} + +func (s *Service) isShuttingDown() bool { + s.webSocketMutex.Lock() + defer s.webSocketMutex.Unlock() + return s.shuttingDown +} + +func (s *Service) startWebSocketShutdown() []*webSocketSession { + s.webSocketMutex.Lock() + defer s.webSocketMutex.Unlock() + + s.shuttingDown = true + + webSocketSessions := make([]*webSocketSession, 0, len(s.webSocketConns)) + for session := range s.webSocketConns { + webSocketSessions = append(webSocketSessions, session) + } + return webSocketSessions +} diff --git a/service/ocm/service_usage.go b/service/ocm/service_usage.go new file mode 100644 index 00000000..589fd093 --- /dev/null +++ b/service/ocm/service_usage.go @@ -0,0 +1,1202 @@ +package ocm + +import ( + "encoding/json" + "fmt" + "math" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" +) + +type UsageStats struct { + RequestCount int `json:"request_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CachedTokens int64 `json:"cached_tokens"` +} + +func (u *UsageStats) UnmarshalJSON(data []byte) error { + type Alias UsageStats + aux := &struct { + *Alias + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + }{ + Alias: (*Alias)(u), + } + err := json.Unmarshal(data, aux) + if err != nil { + return err + } + if u.InputTokens == 0 && aux.PromptTokens > 0 { + u.InputTokens = aux.PromptTokens + } + if u.OutputTokens == 0 && aux.CompletionTokens > 0 { + u.OutputTokens = aux.CompletionTokens + } + return nil +} + +type CostCombination struct { + Model string `json:"model"` + ServiceTier string `json:"service_tier,omitempty"` + ContextWindow int `json:"context_window"` + WeekStartUnix int64 `json:"week_start_unix,omitempty"` + Total UsageStats `json:"total"` + ByUser map[string]UsageStats `json:"by_user"` +} + +type AggregatedUsage struct { + LastUpdated time.Time `json:"last_updated"` + Combinations []CostCombination `json:"combinations"` + mutex sync.Mutex + filePath string + logger log.ContextLogger + lastSaveTime time.Time + pendingSave bool + saveTimer *time.Timer + saveMutex sync.Mutex +} + +type UsageStatsJSON struct { + RequestCount int `json:"request_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CachedTokens int64 `json:"cached_tokens"` + CostUSD float64 `json:"cost_usd"` +} + +type CostCombinationJSON struct { + Model string `json:"model"` + ServiceTier string `json:"service_tier,omitempty"` + ContextWindow int `json:"context_window"` + WeekStartUnix int64 `json:"week_start_unix,omitempty"` + Total UsageStatsJSON `json:"total"` + ByUser map[string]UsageStatsJSON `json:"by_user"` +} + +type CostsSummaryJSON struct { + TotalUSD float64 `json:"total_usd"` + ByUser map[string]float64 `json:"by_user"` + ByWeek map[string]float64 `json:"by_week,omitempty"` + ByUserAndWeek map[string]map[string]float64 `json:"by_user_and_week,omitempty"` +} + +type AggregatedUsageJSON struct { + LastUpdated time.Time `json:"last_updated"` + Costs CostsSummaryJSON `json:"costs"` + Combinations []CostCombinationJSON `json:"combinations"` +} + +type WeeklyCycleHint struct { + WindowMinutes int64 + ResetAt time.Time +} + +type ModelPricing struct { + InputPrice float64 + OutputPrice float64 + CachedInputPrice float64 +} + +type modelFamily struct { + pattern *regexp.Regexp + pricing ModelPricing + premiumPricing *ModelPricing +} + +const ( + serviceTierAuto = "auto" + serviceTierDefault = "default" + serviceTierFlex = "flex" + serviceTierPriority = "priority" + serviceTierScale = "scale" +) + +const ( + contextWindowStandard = 272000 + contextWindowPremium = 1050000 + premiumContextThreshold = 272000 +) + +var ( + gpt52Pricing = ModelPricing{ + InputPrice: 1.75, + OutputPrice: 14.0, + CachedInputPrice: 0.175, + } + + gpt5Pricing = ModelPricing{ + InputPrice: 1.25, + OutputPrice: 10.0, + CachedInputPrice: 0.125, + } + + gpt5MiniPricing = ModelPricing{ + InputPrice: 0.25, + OutputPrice: 2.0, + CachedInputPrice: 0.025, + } + + gpt5NanoPricing = ModelPricing{ + InputPrice: 0.05, + OutputPrice: 0.4, + CachedInputPrice: 0.005, + } + + gpt52CodexPricing = ModelPricing{ + InputPrice: 1.75, + OutputPrice: 14.0, + CachedInputPrice: 0.175, + } + + gpt51CodexPricing = ModelPricing{ + InputPrice: 1.25, + OutputPrice: 10.0, + CachedInputPrice: 0.125, + } + + gpt51CodexMiniPricing = ModelPricing{ + InputPrice: 0.25, + OutputPrice: 2.0, + CachedInputPrice: 0.025, + } + + gpt54StandardPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 15.0, + CachedInputPrice: 0.25, + } + + gpt54PremiumPricing = ModelPricing{ + InputPrice: 5.0, + OutputPrice: 22.5, + CachedInputPrice: 0.5, + } + + gpt54ProPricing = ModelPricing{ + InputPrice: 30.0, + OutputPrice: 180.0, + CachedInputPrice: 30.0, + } + + gpt54ProPremiumPricing = ModelPricing{ + InputPrice: 60.0, + OutputPrice: 270.0, + CachedInputPrice: 60.0, + } + + gpt52ProPricing = ModelPricing{ + InputPrice: 21.0, + OutputPrice: 168.0, + CachedInputPrice: 21.0, + } + + gpt5ProPricing = ModelPricing{ + InputPrice: 15.0, + OutputPrice: 120.0, + CachedInputPrice: 15.0, + } + + gpt54FlexPricing = ModelPricing{ + InputPrice: 1.25, + OutputPrice: 7.5, + CachedInputPrice: 0.125, + } + + gpt54PremiumFlexPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 11.25, + CachedInputPrice: 0.25, + } + + gpt54ProFlexPricing = ModelPricing{ + InputPrice: 15.0, + OutputPrice: 90.0, + CachedInputPrice: 15.0, + } + + gpt54ProPremiumFlexPricing = ModelPricing{ + InputPrice: 30.0, + OutputPrice: 135.0, + CachedInputPrice: 30.0, + } + + gpt52FlexPricing = ModelPricing{ + InputPrice: 0.875, + OutputPrice: 7.0, + CachedInputPrice: 0.0875, + } + + gpt5FlexPricing = ModelPricing{ + InputPrice: 0.625, + OutputPrice: 5.0, + CachedInputPrice: 0.0625, + } + + gpt5MiniFlexPricing = ModelPricing{ + InputPrice: 0.125, + OutputPrice: 1.0, + CachedInputPrice: 0.0125, + } + + gpt5NanoFlexPricing = ModelPricing{ + InputPrice: 0.025, + OutputPrice: 0.2, + CachedInputPrice: 0.0025, + } + + gpt54PriorityPricing = ModelPricing{ + InputPrice: 5.0, + OutputPrice: 30.0, + CachedInputPrice: 0.5, + } + + gpt54PremiumPriorityPricing = ModelPricing{ + InputPrice: 10.0, + OutputPrice: 45.0, + CachedInputPrice: 1.0, + } + + gpt52PriorityPricing = ModelPricing{ + InputPrice: 3.5, + OutputPrice: 28.0, + CachedInputPrice: 0.35, + } + + gpt5PriorityPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 20.0, + CachedInputPrice: 0.25, + } + + gpt5MiniPriorityPricing = ModelPricing{ + InputPrice: 0.45, + OutputPrice: 3.6, + CachedInputPrice: 0.045, + } + + gpt52CodexPriorityPricing = ModelPricing{ + InputPrice: 3.5, + OutputPrice: 28.0, + CachedInputPrice: 0.35, + } + + gpt51CodexPriorityPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 20.0, + CachedInputPrice: 0.25, + } + + gpt4oPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 10.0, + CachedInputPrice: 1.25, + } + + gpt4oMiniPricing = ModelPricing{ + InputPrice: 0.15, + OutputPrice: 0.6, + CachedInputPrice: 0.075, + } + + gpt4oAudioPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 10.0, + CachedInputPrice: 2.5, + } + + gpt4oMiniAudioPricing = ModelPricing{ + InputPrice: 0.15, + OutputPrice: 0.6, + CachedInputPrice: 0.15, + } + + gptAudioMiniPricing = ModelPricing{ + InputPrice: 0.6, + OutputPrice: 2.4, + CachedInputPrice: 0.6, + } + + o1Pricing = ModelPricing{ + InputPrice: 15.0, + OutputPrice: 60.0, + CachedInputPrice: 7.5, + } + + o1ProPricing = ModelPricing{ + InputPrice: 150.0, + OutputPrice: 600.0, + CachedInputPrice: 150.0, + } + + o1MiniPricing = ModelPricing{ + InputPrice: 1.1, + OutputPrice: 4.4, + CachedInputPrice: 0.55, + } + + o3MiniPricing = ModelPricing{ + InputPrice: 1.1, + OutputPrice: 4.4, + CachedInputPrice: 0.55, + } + + o3Pricing = ModelPricing{ + InputPrice: 2.0, + OutputPrice: 8.0, + CachedInputPrice: 0.5, + } + + o3ProPricing = ModelPricing{ + InputPrice: 20.0, + OutputPrice: 80.0, + CachedInputPrice: 20.0, + } + + o3DeepResearchPricing = ModelPricing{ + InputPrice: 10.0, + OutputPrice: 40.0, + CachedInputPrice: 2.5, + } + + o4MiniPricing = ModelPricing{ + InputPrice: 1.1, + OutputPrice: 4.4, + CachedInputPrice: 0.275, + } + + o4MiniDeepResearchPricing = ModelPricing{ + InputPrice: 2.0, + OutputPrice: 8.0, + CachedInputPrice: 0.5, + } + + o3FlexPricing = ModelPricing{ + InputPrice: 1.0, + OutputPrice: 4.0, + CachedInputPrice: 0.25, + } + + o4MiniFlexPricing = ModelPricing{ + InputPrice: 0.55, + OutputPrice: 2.2, + CachedInputPrice: 0.138, + } + + o3PriorityPricing = ModelPricing{ + InputPrice: 3.5, + OutputPrice: 14.0, + CachedInputPrice: 0.875, + } + + o4MiniPriorityPricing = ModelPricing{ + InputPrice: 2.0, + OutputPrice: 8.0, + CachedInputPrice: 0.5, + } + + gpt41Pricing = ModelPricing{ + InputPrice: 2.0, + OutputPrice: 8.0, + CachedInputPrice: 0.5, + } + + gpt41MiniPricing = ModelPricing{ + InputPrice: 0.4, + OutputPrice: 1.6, + CachedInputPrice: 0.1, + } + + gpt41NanoPricing = ModelPricing{ + InputPrice: 0.1, + OutputPrice: 0.4, + CachedInputPrice: 0.025, + } + + gpt41PriorityPricing = ModelPricing{ + InputPrice: 3.5, + OutputPrice: 14.0, + CachedInputPrice: 0.875, + } + + gpt41MiniPriorityPricing = ModelPricing{ + InputPrice: 0.7, + OutputPrice: 2.8, + CachedInputPrice: 0.175, + } + + gpt41NanoPriorityPricing = ModelPricing{ + InputPrice: 0.2, + OutputPrice: 0.8, + CachedInputPrice: 0.05, + } + + gpt4oPriorityPricing = ModelPricing{ + InputPrice: 4.25, + OutputPrice: 17.0, + CachedInputPrice: 2.125, + } + + gpt4oMiniPriorityPricing = ModelPricing{ + InputPrice: 0.25, + OutputPrice: 1.0, + CachedInputPrice: 0.125, + } + + standardModelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^gpt-5\.4-pro(?:$|-)`), + pricing: gpt54ProPricing, + premiumPricing: &gpt54ProPremiumPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.4(?:$|-)`), + pricing: gpt54StandardPricing, + premiumPricing: &gpt54PremiumPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.3-codex(?:$|-)`), + pricing: gpt52CodexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2-codex(?:$|-)`), + pricing: gpt52CodexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1-codex-max(?:$|-)`), + pricing: gpt51CodexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1-codex-mini(?:$|-)`), + pricing: gpt51CodexMiniPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1-codex(?:$|-)`), + pricing: gpt51CodexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-codex-mini(?:$|-)`), + pricing: gpt51CodexMiniPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-codex(?:$|-)`), + pricing: gpt51CodexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2-chat-latest$`), + pricing: gpt52Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1-chat-latest$`), + pricing: gpt5Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-chat-latest$`), + pricing: gpt5Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2-pro(?:$|-)`), + pricing: gpt52ProPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-pro(?:$|-)`), + pricing: gpt5ProPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-mini(?:$|-)`), + pricing: gpt5MiniPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-nano(?:$|-)`), + pricing: gpt5NanoPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2(?:$|-)`), + pricing: gpt52Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1(?:$|-)`), + pricing: gpt5Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5(?:$|-)`), + pricing: gpt5Pricing, + }, + { + pattern: regexp.MustCompile(`^o4-mini-deep-research(?:$|-)`), + pricing: o4MiniDeepResearchPricing, + }, + { + pattern: regexp.MustCompile(`^o4-mini(?:$|-)`), + pricing: o4MiniPricing, + }, + { + pattern: regexp.MustCompile(`^o3-pro(?:$|-)`), + pricing: o3ProPricing, + }, + { + pattern: regexp.MustCompile(`^o3-deep-research(?:$|-)`), + pricing: o3DeepResearchPricing, + }, + { + pattern: regexp.MustCompile(`^o3-mini(?:$|-)`), + pricing: o3MiniPricing, + }, + { + pattern: regexp.MustCompile(`^o3(?:$|-)`), + pricing: o3Pricing, + }, + { + pattern: regexp.MustCompile(`^o1-pro(?:$|-)`), + pricing: o1ProPricing, + }, + { + pattern: regexp.MustCompile(`^o1-mini(?:$|-)`), + pricing: o1MiniPricing, + }, + { + pattern: regexp.MustCompile(`^o1(?:$|-)`), + pricing: o1Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4o-mini-audio(?:$|-)`), + pricing: gpt4oMiniAudioPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-audio-mini(?:$|-)`), + pricing: gptAudioMiniPricing, + }, + { + pattern: regexp.MustCompile(`^(?:gpt-4o-audio|gpt-audio)(?:$|-)`), + pricing: gpt4oAudioPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4\.1-nano(?:$|-)`), + pricing: gpt41NanoPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4\.1-mini(?:$|-)`), + pricing: gpt41MiniPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4\.1(?:$|-)`), + pricing: gpt41Pricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4o-mini(?:$|-)`), + pricing: gpt4oMiniPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4o(?:$|-)`), + pricing: gpt4oPricing, + }, + { + pattern: regexp.MustCompile(`^chatgpt-4o(?:$|-)`), + pricing: gpt4oPricing, + }, + } + + flexModelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^gpt-5\.4-pro(?:$|-)`), + pricing: gpt54ProFlexPricing, + premiumPricing: &gpt54ProPremiumFlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.4(?:$|-)`), + pricing: gpt54FlexPricing, + premiumPricing: &gpt54PremiumFlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-mini(?:$|-)`), + pricing: gpt5MiniFlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-nano(?:$|-)`), + pricing: gpt5NanoFlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2(?:$|-)`), + pricing: gpt52FlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1(?:$|-)`), + pricing: gpt5FlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5(?:$|-)`), + pricing: gpt5FlexPricing, + }, + { + pattern: regexp.MustCompile(`^o4-mini(?:$|-)`), + pricing: o4MiniFlexPricing, + }, + { + pattern: regexp.MustCompile(`^o3(?:$|-)`), + pricing: o3FlexPricing, + }, + } + + priorityModelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^gpt-5\.4(?:$|-)`), + pricing: gpt54PriorityPricing, + premiumPricing: &gpt54PremiumPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.3-codex(?:$|-)`), + pricing: gpt52CodexPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2-codex(?:$|-)`), + pricing: gpt52CodexPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1-codex-max(?:$|-)`), + pricing: gpt51CodexPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1-codex(?:$|-)`), + pricing: gpt51CodexPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-codex-mini(?:$|-)`), + pricing: gpt5MiniPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-codex(?:$|-)`), + pricing: gpt51CodexPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5-mini(?:$|-)`), + pricing: gpt5MiniPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.2(?:$|-)`), + pricing: gpt52PriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.1(?:$|-)`), + pricing: gpt5PriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5(?:$|-)`), + pricing: gpt5PriorityPricing, + }, + { + pattern: regexp.MustCompile(`^o4-mini(?:$|-)`), + pricing: o4MiniPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^o3(?:$|-)`), + pricing: o3PriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4\.1-nano(?:$|-)`), + pricing: gpt41NanoPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4\.1-mini(?:$|-)`), + pricing: gpt41MiniPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4\.1(?:$|-)`), + pricing: gpt41PriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4o-mini(?:$|-)`), + pricing: gpt4oMiniPriorityPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-4o(?:$|-)`), + pricing: gpt4oPriorityPricing, + }, + } +) + +func modelFamiliesForTier(serviceTier string) []modelFamily { + switch serviceTier { + case serviceTierFlex: + return flexModelFamilies + case serviceTierPriority: + return priorityModelFamilies + default: + return standardModelFamilies + } +} + +func findPricingInFamilies(model string, contextWindow int, modelFamilies []modelFamily) (ModelPricing, bool) { + isPremium := contextWindow >= contextWindowPremium + for _, family := range modelFamilies { + if family.pattern.MatchString(model) { + if isPremium && family.premiumPricing != nil { + return *family.premiumPricing, true + } + return family.pricing, true + } + } + return ModelPricing{}, false +} + +func hasPremiumPricingInFamilies(model string, modelFamilies []modelFamily) bool { + for _, family := range modelFamilies { + if family.pattern.MatchString(model) { + return family.premiumPricing != nil + } + } + return false +} + +func normalizeServiceTier(serviceTier string) string { + switch strings.ToLower(strings.TrimSpace(serviceTier)) { + case "", serviceTierAuto, serviceTierDefault: + return serviceTierDefault + case serviceTierFlex: + return serviceTierFlex + case serviceTierPriority: + return serviceTierPriority + case serviceTierScale: + // Scale-tier requests are prepaid differently and not listed in this usage file. + return serviceTierDefault + default: + return serviceTierDefault + } +} + +func getPricing(model string, serviceTier string, contextWindow int) ModelPricing { + normalizedServiceTier := normalizeServiceTier(serviceTier) + families := modelFamiliesForTier(normalizedServiceTier) + + if pricing, found := findPricingInFamilies(model, contextWindow, families); found { + return pricing + } + + normalizedModel := normalizeGPT5Model(model) + if normalizedModel != model { + if pricing, found := findPricingInFamilies(normalizedModel, contextWindow, families); found { + return pricing + } + } + + if normalizedServiceTier != serviceTierDefault { + if pricing, found := findPricingInFamilies(model, contextWindow, standardModelFamilies); found { + return pricing + } + if normalizedModel != model { + if pricing, found := findPricingInFamilies(normalizedModel, contextWindow, standardModelFamilies); found { + return pricing + } + } + } + + return gpt4oPricing +} + +func detectContextWindow(model string, serviceTier string, inputTokens int64) int { + if inputTokens <= premiumContextThreshold { + return contextWindowStandard + } + normalizedServiceTier := normalizeServiceTier(serviceTier) + families := modelFamiliesForTier(normalizedServiceTier) + if hasPremiumPricingInFamilies(model, families) { + return contextWindowPremium + } + normalizedModel := normalizeGPT5Model(model) + if normalizedModel != model && hasPremiumPricingInFamilies(normalizedModel, families) { + return contextWindowPremium + } + if normalizedServiceTier != serviceTierDefault { + if hasPremiumPricingInFamilies(model, standardModelFamilies) { + return contextWindowPremium + } + if normalizedModel != model && hasPremiumPricingInFamilies(normalizedModel, standardModelFamilies) { + return contextWindowPremium + } + } + return contextWindowStandard +} + +func normalizeGPT5Model(model string) string { + if !strings.HasPrefix(model, "gpt-5.") { + return model + } + + switch { + case strings.Contains(model, "-codex-mini"): + return "gpt-5.1-codex-mini" + case strings.Contains(model, "-codex-max"): + return "gpt-5.1-codex-max" + case strings.Contains(model, "-codex"): + return "gpt-5.3-codex" + case strings.Contains(model, "-chat-latest"): + return "gpt-5.2-chat-latest" + case strings.Contains(model, "-pro"): + return "gpt-5.4-pro" + case strings.Contains(model, "-mini"): + return "gpt-5-mini" + case strings.Contains(model, "-nano"): + return "gpt-5-nano" + default: + return "gpt-5.4" + } +} + +func calculateCost(stats UsageStats, model string, serviceTier string, contextWindow int) float64 { + pricing := getPricing(model, serviceTier, contextWindow) + + regularInputTokens := stats.InputTokens - stats.CachedTokens + if regularInputTokens < 0 { + regularInputTokens = 0 + } + + cost := (float64(regularInputTokens)*pricing.InputPrice + + float64(stats.OutputTokens)*pricing.OutputPrice + + float64(stats.CachedTokens)*pricing.CachedInputPrice) / 1_000_000 + + return math.Round(cost*100) / 100 +} + +func roundCost(cost float64) float64 { + return math.Round(cost*100) / 100 +} + +func normalizeCombinations(combinations []CostCombination) { + for index := range combinations { + combinations[index].ServiceTier = normalizeServiceTier(combinations[index].ServiceTier) + if combinations[index].ContextWindow <= 0 { + combinations[index].ContextWindow = contextWindowStandard + } + if combinations[index].ByUser == nil { + combinations[index].ByUser = make(map[string]UsageStats) + } + } +} + +func addUsageToCombinations(combinations *[]CostCombination, model string, serviceTier string, contextWindow int, weekStartUnix int64, user string, inputTokens, outputTokens, cachedTokens int64) { + var matchedCombination *CostCombination + for index := range *combinations { + combination := &(*combinations)[index] + combinationServiceTier := normalizeServiceTier(combination.ServiceTier) + if combination.ServiceTier != combinationServiceTier { + combination.ServiceTier = combinationServiceTier + } + if combination.Model == model && combinationServiceTier == serviceTier && combination.ContextWindow == contextWindow && combination.WeekStartUnix == weekStartUnix { + matchedCombination = combination + break + } + } + + if matchedCombination == nil { + newCombination := CostCombination{ + Model: model, + ServiceTier: serviceTier, + ContextWindow: contextWindow, + WeekStartUnix: weekStartUnix, + Total: UsageStats{}, + ByUser: make(map[string]UsageStats), + } + *combinations = append(*combinations, newCombination) + matchedCombination = &(*combinations)[len(*combinations)-1] + } + + matchedCombination.Total.RequestCount++ + matchedCombination.Total.InputTokens += inputTokens + matchedCombination.Total.OutputTokens += outputTokens + matchedCombination.Total.CachedTokens += cachedTokens + + if user != "" { + userStats := matchedCombination.ByUser[user] + userStats.RequestCount++ + userStats.InputTokens += inputTokens + userStats.OutputTokens += outputTokens + userStats.CachedTokens += cachedTokens + matchedCombination.ByUser[user] = userStats + } +} + +func buildCombinationJSON(combinations []CostCombination, aggregateUserCosts map[string]float64) ([]CostCombinationJSON, float64) { + result := make([]CostCombinationJSON, len(combinations)) + var totalCost float64 + + for index, combination := range combinations { + combinationTotalCost := calculateCost(combination.Total, combination.Model, combination.ServiceTier, combination.ContextWindow) + totalCost += combinationTotalCost + + combinationJSON := CostCombinationJSON{ + Model: combination.Model, + ServiceTier: combination.ServiceTier, + ContextWindow: combination.ContextWindow, + WeekStartUnix: combination.WeekStartUnix, + Total: UsageStatsJSON{ + RequestCount: combination.Total.RequestCount, + InputTokens: combination.Total.InputTokens, + OutputTokens: combination.Total.OutputTokens, + CachedTokens: combination.Total.CachedTokens, + CostUSD: combinationTotalCost, + }, + ByUser: make(map[string]UsageStatsJSON), + } + + for user, userStats := range combination.ByUser { + userCost := calculateCost(userStats, combination.Model, combination.ServiceTier, combination.ContextWindow) + if aggregateUserCosts != nil { + aggregateUserCosts[user] += userCost + } + + combinationJSON.ByUser[user] = UsageStatsJSON{ + RequestCount: userStats.RequestCount, + InputTokens: userStats.InputTokens, + OutputTokens: userStats.OutputTokens, + CachedTokens: userStats.CachedTokens, + CostUSD: userCost, + } + } + + result[index] = combinationJSON + } + + return result, roundCost(totalCost) +} + +func formatUTCOffsetLabel(timestamp time.Time) string { + _, offsetSeconds := timestamp.Zone() + sign := "+" + if offsetSeconds < 0 { + sign = "-" + offsetSeconds = -offsetSeconds + } + offsetHours := offsetSeconds / 3600 + offsetMinutes := (offsetSeconds % 3600) / 60 + if offsetMinutes == 0 { + return fmt.Sprintf("UTC%s%d", sign, offsetHours) + } + return fmt.Sprintf("UTC%s%d:%02d", sign, offsetHours, offsetMinutes) +} + +func formatWeekStartKey(cycleStartAt time.Time) string { + localCycleStart := cycleStartAt.In(time.Local) + return fmt.Sprintf("%s %s", localCycleStart.Format("2006-01-02 15:04:05"), formatUTCOffsetLabel(localCycleStart)) +} + +func buildByWeekCost(combinations []CostCombination) map[string]float64 { + byWeek := make(map[string]float64) + for _, combination := range combinations { + if combination.WeekStartUnix <= 0 { + continue + } + weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() + weekKey := formatWeekStartKey(weekStartAt) + byWeek[weekKey] += calculateCost(combination.Total, combination.Model, combination.ServiceTier, combination.ContextWindow) + } + for weekKey, weekCost := range byWeek { + byWeek[weekKey] = roundCost(weekCost) + } + return byWeek +} + +func buildByUserAndWeekCost(combinations []CostCombination) map[string]map[string]float64 { + byUserAndWeek := make(map[string]map[string]float64) + for _, combination := range combinations { + if combination.WeekStartUnix <= 0 { + continue + } + weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() + weekKey := formatWeekStartKey(weekStartAt) + for user, userStats := range combination.ByUser { + userWeeks, exists := byUserAndWeek[user] + if !exists { + userWeeks = make(map[string]float64) + byUserAndWeek[user] = userWeeks + } + userWeeks[weekKey] += calculateCost(userStats, combination.Model, combination.ServiceTier, combination.ContextWindow) + } + } + for _, weekCosts := range byUserAndWeek { + for weekKey, cost := range weekCosts { + weekCosts[weekKey] = roundCost(cost) + } + } + return byUserAndWeek +} + +func deriveWeekStartUnix(cycleHint *WeeklyCycleHint) int64 { + if cycleHint == nil || cycleHint.WindowMinutes <= 0 || cycleHint.ResetAt.IsZero() { + return 0 + } + windowDuration := time.Duration(cycleHint.WindowMinutes) * time.Minute + return cycleHint.ResetAt.UTC().Add(-windowDuration).Unix() +} + +func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON { + u.mutex.Lock() + defer u.mutex.Unlock() + + result := &AggregatedUsageJSON{ + LastUpdated: u.LastUpdated, + Costs: CostsSummaryJSON{ + TotalUSD: 0, + ByUser: make(map[string]float64), + ByWeek: make(map[string]float64), + }, + } + + globalCombinationsJSON, totalCost := buildCombinationJSON(u.Combinations, result.Costs.ByUser) + result.Combinations = globalCombinationsJSON + result.Costs.TotalUSD = totalCost + result.Costs.ByWeek = buildByWeekCost(u.Combinations) + + if len(result.Costs.ByWeek) == 0 { + result.Costs.ByWeek = nil + } + + result.Costs.ByUserAndWeek = buildByUserAndWeekCost(u.Combinations) + if len(result.Costs.ByUserAndWeek) == 0 { + result.Costs.ByUserAndWeek = nil + } + + for user, cost := range result.Costs.ByUser { + result.Costs.ByUser[user] = roundCost(cost) + } + + return result +} + +func (u *AggregatedUsage) Load() error { + u.mutex.Lock() + defer u.mutex.Unlock() + + u.LastUpdated = time.Time{} + u.Combinations = nil + + data, err := os.ReadFile(u.filePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var temp struct { + LastUpdated time.Time `json:"last_updated"` + Combinations []CostCombination `json:"combinations"` + } + + err = json.Unmarshal(data, &temp) + if err != nil { + return err + } + + u.LastUpdated = temp.LastUpdated + u.Combinations = temp.Combinations + normalizeCombinations(u.Combinations) + + return nil +} + +func (u *AggregatedUsage) Save() error { + jsonData := u.ToJSON() + + data, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return err + } + + tmpFile := u.filePath + ".tmp" + err = os.WriteFile(tmpFile, data, 0o644) + if err != nil { + return err + } + defer os.Remove(tmpFile) + err = os.Rename(tmpFile, u.filePath) + if err == nil { + u.saveMutex.Lock() + u.lastSaveTime = time.Now() + u.saveMutex.Unlock() + } + return err +} + +func (u *AggregatedUsage) AddUsage(model string, contextWindow int, inputTokens, outputTokens, cachedTokens int64, serviceTier string, user string) error { + return u.AddUsageWithCycleHint(model, contextWindow, inputTokens, outputTokens, cachedTokens, serviceTier, user, time.Now(), nil) +} + +func (u *AggregatedUsage) AddUsageWithCycleHint(model string, contextWindow int, inputTokens, outputTokens, cachedTokens int64, serviceTier string, user string, observedAt time.Time, cycleHint *WeeklyCycleHint) error { + if model == "" { + return E.New("model cannot be empty") + } + if contextWindow <= 0 { + return E.New("contextWindow must be positive") + } + + normalizedServiceTier := normalizeServiceTier(serviceTier) + if observedAt.IsZero() { + observedAt = time.Now() + } + + u.mutex.Lock() + defer u.mutex.Unlock() + + u.LastUpdated = observedAt + weekStartUnix := deriveWeekStartUnix(cycleHint) + + addUsageToCombinations(&u.Combinations, model, normalizedServiceTier, contextWindow, weekStartUnix, user, inputTokens, outputTokens, cachedTokens) + + go u.scheduleSave() + + return nil +} + +func (u *AggregatedUsage) scheduleSave() { + const saveInterval = time.Minute + + u.saveMutex.Lock() + defer u.saveMutex.Unlock() + + timeSinceLastSave := time.Since(u.lastSaveTime) + + if timeSinceLastSave >= saveInterval { + go u.saveAsync() + return + } + + if u.pendingSave { + return + } + + u.pendingSave = true + remainingTime := saveInterval - timeSinceLastSave + + u.saveTimer = time.AfterFunc(remainingTime, func() { + u.saveMutex.Lock() + u.pendingSave = false + u.saveMutex.Unlock() + u.saveAsync() + }) +} + +func (u *AggregatedUsage) saveAsync() { + err := u.Save() + if err != nil { + if u.logger != nil { + u.logger.Error("save usage statistics: ", err) + } + } +} + +func (u *AggregatedUsage) cancelPendingSave() { + u.saveMutex.Lock() + defer u.saveMutex.Unlock() + + if u.saveTimer != nil { + u.saveTimer.Stop() + u.saveTimer = nil + } + u.pendingSave = false +} diff --git a/service/ocm/service_user.go b/service/ocm/service_user.go new file mode 100644 index 00000000..494b981b --- /dev/null +++ b/service/ocm/service_user.go @@ -0,0 +1,29 @@ +package ocm + +import ( + "sync" + + "github.com/sagernet/sing-box/option" +) + +type UserManager struct { + accessMutex sync.RWMutex + tokenMap map[string]string +} + +func (m *UserManager) UpdateUsers(users []option.OCMUser) { + m.accessMutex.Lock() + defer m.accessMutex.Unlock() + tokenMap := make(map[string]string, len(users)) + for _, user := range users { + tokenMap[user.Token] = user.Name + } + m.tokenMap = tokenMap +} + +func (m *UserManager) Authenticate(token string) (string, bool) { + m.accessMutex.RLock() + username, found := m.tokenMap[token] + m.accessMutex.RUnlock() + return username, found +} diff --git a/service/ocm/service_websocket.go b/service/ocm/service_websocket.go new file mode 100644 index 00000000..d19f2df8 --- /dev/null +++ b/service/ocm/service_websocket.go @@ -0,0 +1,285 @@ +package ocm + +import ( + "context" + stdTLS "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/ws" + "github.com/sagernet/ws/wsutil" + + "github.com/openai/openai-go/v3/responses" +) + +type webSocketSession struct { + clientConn net.Conn + upstreamConn net.Conn + closeOnce sync.Once +} + +func (s *webSocketSession) Close() { + s.closeOnce.Do(func() { + s.clientConn.Close() + s.upstreamConn.Close() + }) +} + +func buildUpstreamWebSocketURL(baseURL string, proxyPath string) string { + upstreamURL := baseURL + if strings.HasPrefix(upstreamURL, "https://") { + upstreamURL = "wss://" + upstreamURL[len("https://"):] + } else if strings.HasPrefix(upstreamURL, "http://") { + upstreamURL = "ws://" + upstreamURL[len("http://"):] + } + return upstreamURL + proxyPath +} + +func isForwardableResponseHeader(key string) bool { + lowerKey := strings.ToLower(key) + switch { + case strings.HasPrefix(lowerKey, "x-codex-"): + return true + case strings.HasPrefix(lowerKey, "x-reasoning"): + return true + case lowerKey == "openai-model": + return true + case strings.Contains(lowerKey, "-secondary-"): + return true + default: + return false + } +} + +func isForwardableWebSocketRequestHeader(key string) bool { + if isHopByHopHeader(key) { + return false + } + + lowerKey := strings.ToLower(key) + switch { + case lowerKey == "authorization": + return false + case strings.HasPrefix(lowerKey, "sec-websocket-"): + return false + default: + return true + } +} + +func (s *Service) handleWebSocket(w http.ResponseWriter, r *http.Request, proxyPath string, username string) { + accessToken, err := s.getAccessToken() + if err != nil { + s.logger.Error("get access token for websocket: ", err) + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "authentication failed") + return + } + + upstreamURL := buildUpstreamWebSocketURL(s.getBaseURL(), proxyPath) + if r.URL.RawQuery != "" { + upstreamURL += "?" + r.URL.RawQuery + } + + upstreamHeaders := make(http.Header) + for key, values := range r.Header { + if isForwardableWebSocketRequestHeader(key) { + upstreamHeaders[key] = values + } + } + for key, values := range s.httpHeaders { + upstreamHeaders.Del(key) + upstreamHeaders[key] = values + } + upstreamHeaders.Set("Authorization", "Bearer "+accessToken) + if accountID := s.getAccountID(); accountID != "" { + upstreamHeaders.Set("ChatGPT-Account-Id", accountID) + } + + upstreamResponseHeaders := make(http.Header) + upstreamDialer := ws.Dialer{ + NetDial: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSConfig: &stdTLS.Config{ + RootCAs: adapter.RootPoolFromContext(s.ctx), + Time: ntp.TimeFuncFromContext(s.ctx), + }, + Header: ws.HandshakeHeaderHTTP(upstreamHeaders), + OnHeader: func(key, value []byte) error { + upstreamResponseHeaders.Add(string(key), string(value)) + return nil + }, + } + + upstreamConn, upstreamBufferedReader, _, err := upstreamDialer.Dial(r.Context(), upstreamURL) + if err != nil { + s.logger.Error("dial upstream websocket: ", err) + writeJSONError(w, r, http.StatusBadGateway, "api_error", "upstream websocket connection failed") + return + } + + weeklyCycleHint := extractWeeklyCycleHint(upstreamResponseHeaders) + + clientResponseHeaders := make(http.Header) + for key, values := range upstreamResponseHeaders { + if isForwardableResponseHeader(key) { + clientResponseHeaders[key] = values + } + } + + clientUpgrader := ws.HTTPUpgrader{ + Header: clientResponseHeaders, + } + if s.isShuttingDown() { + upstreamConn.Close() + writeJSONError(w, r, http.StatusServiceUnavailable, "api_error", "service is shutting down") + return + } + clientConn, _, _, err := clientUpgrader.Upgrade(r, w) + if err != nil { + s.logger.Error("upgrade client websocket: ", err) + upstreamConn.Close() + return + } + session := &webSocketSession{ + clientConn: clientConn, + upstreamConn: upstreamConn, + } + if !s.registerWebSocketSession(session) { + session.Close() + return + } + defer s.unregisterWebSocketSession(session) + + var upstreamReadWriter io.ReadWriter + if upstreamBufferedReader != nil { + upstreamReadWriter = struct { + io.Reader + io.Writer + }{upstreamBufferedReader, upstreamConn} + } else { + upstreamReadWriter = upstreamConn + } + + modelChannel := make(chan string, 1) + var waitGroup sync.WaitGroup + + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + defer session.Close() + s.proxyWebSocketClientToUpstream(clientConn, upstreamConn, modelChannel) + }() + go func() { + defer waitGroup.Done() + defer session.Close() + s.proxyWebSocketUpstreamToClient(upstreamReadWriter, clientConn, modelChannel, username, weeklyCycleHint) + }() + waitGroup.Wait() +} + +func (s *Service) proxyWebSocketClientToUpstream(clientConn net.Conn, upstreamConn net.Conn, modelChannel chan<- string) { + for { + data, opCode, err := wsutil.ReadClientData(clientConn) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("read client websocket: ", err) + } + return + } + + if opCode == ws.OpText && s.usageTracker != nil { + var request struct { + Type string `json:"type"` + Model string `json:"model"` + } + if json.Unmarshal(data, &request) == nil && request.Type == "response.create" && request.Model != "" { + select { + case modelChannel <- request.Model: + default: + } + } + } + + err = wsutil.WriteClientMessage(upstreamConn, opCode, data) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("write upstream websocket: ", err) + } + return + } + } +} + +func (s *Service) proxyWebSocketUpstreamToClient(upstreamReadWriter io.ReadWriter, clientConn net.Conn, modelChannel <-chan string, username string, weeklyCycleHint *WeeklyCycleHint) { + var requestModel string + for { + data, opCode, err := wsutil.ReadServerData(upstreamReadWriter) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("read upstream websocket: ", err) + } + return + } + + if opCode == ws.OpText && s.usageTracker != nil { + select { + case model := <-modelChannel: + requestModel = model + default: + } + + var event struct { + Type string `json:"type"` + } + if json.Unmarshal(data, &event) == nil && event.Type == "response.completed" { + var streamEvent responses.ResponseStreamEventUnion + if json.Unmarshal(data, &streamEvent) == nil { + completedEvent := streamEvent.AsResponseCompleted() + responseModel := string(completedEvent.Response.Model) + serviceTier := string(completedEvent.Response.ServiceTier) + inputTokens := completedEvent.Response.Usage.InputTokens + outputTokens := completedEvent.Response.Usage.OutputTokens + cachedTokens := completedEvent.Response.Usage.InputTokensDetails.CachedTokens + + if inputTokens > 0 || outputTokens > 0 { + if responseModel == "" { + responseModel = requestModel + } + if responseModel != "" { + contextWindow := detectContextWindow(responseModel, serviceTier, inputTokens) + s.usageTracker.AddUsageWithCycleHint( + responseModel, + contextWindow, + inputTokens, + outputTokens, + cachedTokens, + serviceTier, + username, + time.Now(), + weeklyCycleHint, + ) + } + } + } + } + } + + err = wsutil.WriteServerMessage(clientConn, opCode, data) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("write client websocket: ", err) + } + return + } + } +} diff --git a/service/oomkiller/policy.go b/service/oomkiller/policy.go new file mode 100644 index 00000000..aa744301 --- /dev/null +++ b/service/oomkiller/policy.go @@ -0,0 +1,46 @@ +package oomkiller + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/memory" + "github.com/sagernet/sing/service" +) + +const DefaultAppleNetworkExtensionMemoryLimit = 50 * 1024 * 1024 + +type policyMode uint8 + +const ( + policyModeNone policyMode = iota + policyModeMemoryLimit + policyModeAvailable + policyModeNetworkExtension +) + +func (m policyMode) hasTimerMode() bool { + return m != policyModeNone +} + +func resolvePolicyMode(ctx context.Context, options option.OOMKillerServiceOptions) (uint64, policyMode) { + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + if C.IsIos && platformInterface != nil && platformInterface.UnderNetworkExtension() { + return DefaultAppleNetworkExtensionMemoryLimit, policyModeNetworkExtension + } + if options.MemoryLimitOverride > 0 { + return options.MemoryLimitOverride, policyModeMemoryLimit + } + if options.MemoryLimit != nil { + memoryLimit := options.MemoryLimit.Value() + if memoryLimit > 0 { + return memoryLimit, policyModeMemoryLimit + } + } + if memory.AvailableAvailable() { + return 0, policyModeAvailable + } + return 0, policyModeNone +} diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go new file mode 100644 index 00000000..ec3838d2 --- /dev/null +++ b/service/oomkiller/service.go @@ -0,0 +1,83 @@ +package oomkiller + +import ( + "context" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + boxConstant "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/service" +) + +type OOMReporter interface { + WriteReport(memoryUsage uint64) error +} + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.OOMKillerServiceOptions](registry, boxConstant.TypeOOMKiller, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + router adapter.Router + timerConfig timerConfig + adaptiveTimer *adaptiveTimer + lastReportTime atomic.Int64 +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) { + memoryLimit, mode := resolvePolicyMode(ctx, options) + config, err := buildTimerConfig(options, memoryLimit, mode, options.KillerDisabled) + if err != nil { + return nil, err + } + return &Service{ + Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag), + ctx: ctx, + logger: logger, + router: service.FromContext[adapter.Router](ctx), + timerConfig: config, + }, nil +} + +func (s *Service) createTimer() { + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig, s.writeOOMReport) +} + +func (s *Service) startTimer() { + s.createTimer() + s.adaptiveTimer.start() +} + +func (s *Service) stopTimer() { + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() + } +} + +func (s *Service) writeOOMReport(memoryUsage uint64) { + now := time.Now().Unix() + lastReport := s.lastReportTime.Load() + if now-lastReport < 3600 { + return + } + if !s.lastReportTime.CompareAndSwap(lastReport, now) { + return + } + reporter := service.FromContext[OOMReporter](s.ctx) + if reporter == nil { + return + } + err := reporter.WriteReport(memoryUsage) + if err != nil { + s.logger.Warn("failed to write OOM report: ", err) + } else { + s.logger.Info("OOM report saved") + } +} diff --git a/service/oomkiller/service_darwin.go b/service/oomkiller/service_darwin.go new file mode 100644 index 00000000..1d51c1b4 --- /dev/null +++ b/service/oomkiller/service_darwin.go @@ -0,0 +1,105 @@ +//go:build darwin && cgo + +package oomkiller + +/* +#include + +static dispatch_source_t memoryPressureSource; + +extern void goMemoryPressureCallback(unsigned long status); + +static void startMemoryPressureMonitor() { + memoryPressureSource = dispatch_source_create( + DISPATCH_SOURCE_TYPE_MEMORYPRESSURE, + 0, + DISPATCH_MEMORYPRESSURE_CRITICAL, + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0) + ); + dispatch_source_set_event_handler(memoryPressureSource, ^{ + unsigned long status = dispatch_source_get_data(memoryPressureSource); + goMemoryPressureCallback(status); + }); + dispatch_activate(memoryPressureSource); +} + +static void stopMemoryPressureMonitor() { + if (memoryPressureSource) { + dispatch_source_cancel(memoryPressureSource); + memoryPressureSource = NULL; + } +} +*/ +import "C" + +import ( + runtimeDebug "runtime/debug" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" +) + +var ( + globalAccess sync.Mutex + globalServices []*Service +) + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if s.timerConfig.policyMode == policyModeNetworkExtension { + s.createTimer() + globalAccess.Lock() + isFirst := len(globalServices) == 0 + globalServices = append(globalServices, s) + globalAccess.Unlock() + if isFirst { + C.startMemoryPressureMonitor() + } + return nil + } + if !s.timerConfig.policyMode.hasTimerMode() { + return E.New("memory pressure monitoring is not available on this platform without memory_limit") + } + s.startTimer() + return nil +} + +func (s *Service) Close() error { + s.stopTimer() + if s.timerConfig.policyMode == policyModeNetworkExtension { + globalAccess.Lock() + for i, svc := range globalServices { + if svc == s { + globalServices = append(globalServices[:i], globalServices[i+1:]...) + break + } + } + isLast := len(globalServices) == 0 + globalAccess.Unlock() + if isLast { + C.stopMemoryPressureMonitor() + } + } + return nil +} + +//export goMemoryPressureCallback +func goMemoryPressureCallback(status C.ulong) { + runtimeDebug.FreeOSMemory() + globalAccess.Lock() + services := make([]*Service, len(globalServices)) + copy(services, globalServices) + globalAccess.Unlock() + if len(services) == 0 { + return + } + sample := readMemorySample(policyModeNetworkExtension) + for _, s := range services { + s.logger.Warn("memory pressure: critical, usage: ", byteformats.FormatMemoryBytes(sample.usage)) + s.adaptiveTimer.notifyPressure() + } +} diff --git a/service/oomkiller/service_stub.go b/service/oomkiller/service_stub.go new file mode 100644 index 00000000..5eaf8204 --- /dev/null +++ b/service/oomkiller/service_stub.go @@ -0,0 +1,24 @@ +//go:build !darwin || !cgo + +package oomkiller + +import ( + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" +) + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if !s.timerConfig.policyMode.hasTimerMode() { + return E.New("memory pressure monitoring is not available on this platform without memory_limit") + } + s.startTimer() + return nil +} + +func (s *Service) Close() error { + s.stopTimer() + return nil +} diff --git a/service/oomkiller/timer.go b/service/oomkiller/timer.go new file mode 100644 index 00000000..a5bef3a7 --- /dev/null +++ b/service/oomkiller/timer.go @@ -0,0 +1,338 @@ +package oomkiller + +import ( + runtimeDebug "runtime/debug" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/memory" +) + +const ( + defaultMinInterval = 100 * time.Millisecond + defaultArmedInterval = time.Second + defaultMaxInterval = 10 * time.Second + defaultSafetyMargin = 5 * 1024 * 1024 + defaultAvailableTriggerMarginMin = 32 * 1024 * 1024 + defaultAvailableTriggerMarginMax = 128 * 1024 * 1024 +) + +type pressureState uint8 + +const ( + pressureStateNormal pressureState = iota + pressureStateArmed + pressureStateTriggered +) + +type memorySample struct { + usage uint64 + available uint64 + availableKnown bool +} + +type pressureThresholds struct { + trigger uint64 + armed uint64 + resume uint64 +} + +type timerConfig struct { + memoryLimit uint64 + safetyMargin uint64 + hasSafetyMargin bool + minInterval time.Duration + armedInterval time.Duration + maxInterval time.Duration + policyMode policyMode + killerDisabled bool +} + +func buildTimerConfig(options option.OOMKillerServiceOptions, memoryLimit uint64, policyMode policyMode, killerDisabled bool) (timerConfig, error) { + minInterval := defaultMinInterval + if options.MinInterval != 0 { + minInterval = time.Duration(options.MinInterval.Build()) + if minInterval <= 0 { + return timerConfig{}, E.New("min_interval must be greater than 0") + } + } + + maxInterval := defaultMaxInterval + if options.MaxInterval != 0 { + maxInterval = time.Duration(options.MaxInterval.Build()) + if maxInterval <= 0 { + return timerConfig{}, E.New("max_interval must be greater than 0") + } + } + if maxInterval < minInterval { + return timerConfig{}, E.New("max_interval must be greater than or equal to min_interval") + } + + var ( + safetyMargin uint64 + hasSafetyMargin bool + ) + if options.SafetyMargin != nil && options.SafetyMargin.Value() > 0 { + safetyMargin = options.SafetyMargin.Value() + hasSafetyMargin = true + } else if memoryLimit > 0 { + safetyMargin = defaultSafetyMargin + hasSafetyMargin = true + } + + return timerConfig{ + memoryLimit: memoryLimit, + safetyMargin: safetyMargin, + hasSafetyMargin: hasSafetyMargin, + minInterval: minInterval, + armedInterval: max(min(defaultArmedInterval, maxInterval), minInterval), + maxInterval: maxInterval, + policyMode: policyMode, + killerDisabled: killerDisabled, + }, nil +} + +type adaptiveTimer struct { + timerConfig + logger log.ContextLogger + router adapter.Router + onTriggered func(uint64) + limitThresholds pressureThresholds + + access sync.Mutex + timer *time.Timer + state pressureState + currentInterval time.Duration + forceMinInterval bool + pendingPressureBaseline bool + pressureBaseline memorySample + pressureBaselineTime time.Time +} + +func newAdaptiveTimer(logger log.ContextLogger, router adapter.Router, config timerConfig, onTriggered func(uint64)) *adaptiveTimer { + t := &adaptiveTimer{ + timerConfig: config, + logger: logger, + router: router, + onTriggered: onTriggered, + } + if config.policyMode == policyModeMemoryLimit || config.policyMode == policyModeNetworkExtension { + t.limitThresholds = computeLimitThresholds(config.memoryLimit, config.safetyMargin) + } + return t +} + +func (t *adaptiveTimer) start() { + t.access.Lock() + defer t.access.Unlock() + t.startLocked() +} + +func (t *adaptiveTimer) notifyPressure() { + t.access.Lock() + t.startLocked() + t.forceMinInterval = true + t.pendingPressureBaseline = true + t.access.Unlock() + t.poll() +} + +func (t *adaptiveTimer) startLocked() { + if t.timer != nil { + return + } + t.state = pressureStateNormal + t.forceMinInterval = false + t.timer = time.AfterFunc(t.minInterval, t.poll) +} + +func (t *adaptiveTimer) stop() { + t.access.Lock() + defer t.access.Unlock() + if t.timer != nil { + t.timer.Stop() + t.timer = nil + } +} + +func (t *adaptiveTimer) poll() { + if t.timerConfig.policyMode == policyModeNetworkExtension { + runtimeDebug.FreeOSMemory() + } + + var triggered bool + var rateTriggered bool + sample := readMemorySample(t.policyMode) + + t.access.Lock() + if t.timer == nil { + t.access.Unlock() + return + } + if t.pendingPressureBaseline { + t.pressureBaseline = sample + t.pressureBaselineTime = time.Now() + t.pendingPressureBaseline = false + } + previousState := t.state + t.state = t.nextState(sample) + if t.state == pressureStateNormal { + t.forceMinInterval = false + if !t.pressureBaselineTime.IsZero() && time.Since(t.pressureBaselineTime) > t.maxInterval { + t.pressureBaselineTime = time.Time{} + } + } + t.timer.Reset(t.intervalForState()) + triggered = previousState != pressureStateTriggered && t.state == pressureStateTriggered + if !triggered && !t.pressureBaselineTime.IsZero() && t.memoryLimit > 0 && + sample.usage > t.pressureBaseline.usage && sample.usage < t.memoryLimit { + elapsed := time.Since(t.pressureBaselineTime) + if elapsed >= t.minInterval/2 { + growth := sample.usage - t.pressureBaseline.usage + ratePerSecond := float64(growth) / elapsed.Seconds() + headroom := t.memoryLimit - sample.usage + timeToLimit := time.Duration(float64(headroom)/ratePerSecond) * time.Second + if timeToLimit < t.minInterval { + triggered = true + rateTriggered = true + t.state = pressureStateTriggered + } + } + } + t.access.Unlock() + + if !triggered { + return + } + t.onTriggered(sample.usage) + if rateTriggered { + if t.killerDisabled { + t.logger.Warn("memory growth rate critical (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) + } else { + t.logger.Error("memory growth rate critical, usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample), ", resetting network") + t.router.ResetNetwork() + } + } else { + if t.killerDisabled { + t.logger.Warn("memory threshold reached (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) + } else { + t.logger.Error("memory threshold reached, usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample), ", resetting network") + t.router.ResetNetwork() + } + } + runtimeDebug.FreeOSMemory() +} + +func (t *adaptiveTimer) nextState(sample memorySample) pressureState { + switch t.policyMode { + case policyModeMemoryLimit, policyModeNetworkExtension: + return nextPressureState(t.state, + sample.usage >= t.limitThresholds.trigger, + sample.usage >= t.limitThresholds.armed, + sample.usage >= t.limitThresholds.resume, + ) + case policyModeAvailable: + if !sample.availableKnown { + return pressureStateNormal + } + thresholds := t.availableThresholds(sample) + return nextPressureState(t.state, + sample.available <= thresholds.trigger, + sample.available <= thresholds.armed, + sample.available <= thresholds.resume, + ) + default: + return pressureStateNormal + } +} + +func computeLimitThresholds(memoryLimit uint64, safetyMargin uint64) pressureThresholds { + triggerMargin := min(safetyMargin, memoryLimit) + armedMargin := min(triggerMargin*2, memoryLimit) + resumeMargin := min(triggerMargin*4, memoryLimit) + return pressureThresholds{ + trigger: memoryLimit - triggerMargin, + armed: memoryLimit - armedMargin, + resume: memoryLimit - resumeMargin, + } +} + +func (t *adaptiveTimer) availableThresholds(sample memorySample) pressureThresholds { + var triggerMargin uint64 + if t.hasSafetyMargin { + triggerMargin = t.safetyMargin + } else if sample.usage == 0 { + triggerMargin = defaultAvailableTriggerMarginMin + } else { + triggerMargin = max(defaultAvailableTriggerMarginMin, min(sample.usage/4, defaultAvailableTriggerMarginMax)) + } + return pressureThresholds{ + trigger: triggerMargin, + armed: triggerMargin * 2, + resume: triggerMargin * 4, + } +} + +func (t *adaptiveTimer) intervalForState() time.Duration { + switch { + case t.forceMinInterval || t.state == pressureStateTriggered: + t.currentInterval = t.minInterval + case t.state == pressureStateArmed: + t.currentInterval = t.armedInterval + default: + if t.currentInterval == 0 { + t.currentInterval = t.maxInterval + } else { + t.currentInterval = min(t.currentInterval*2, t.maxInterval) + } + } + return t.currentInterval +} + +func (t *adaptiveTimer) logDetails(sample memorySample) string { + switch t.policyMode { + case policyModeMemoryLimit, policyModeNetworkExtension: + headroom := uint64(0) + if sample.usage < t.memoryLimit { + headroom = t.memoryLimit - sample.usage + } + return ", limit: " + byteformats.FormatMemoryBytes(t.memoryLimit) + ", headroom: " + byteformats.FormatMemoryBytes(headroom) + case policyModeAvailable: + if sample.availableKnown { + return ", available: " + byteformats.FormatMemoryBytes(sample.available) + } + } + return "" +} + +func nextPressureState(current pressureState, shouldTrigger, shouldArm, shouldStayTriggered bool) pressureState { + if current == pressureStateTriggered { + if shouldStayTriggered { + return pressureStateTriggered + } + return pressureStateNormal + } + if shouldTrigger { + return pressureStateTriggered + } + if shouldArm { + return pressureStateArmed + } + return pressureStateNormal +} + +func readMemorySample(mode policyMode) memorySample { + sample := memorySample{ + usage: memory.Total(), + } + if mode == policyModeAvailable { + sample.availableKnown = true + sample.available = memory.Available() + } + return sample +} diff --git a/service/origin_ca/service.go b/service/origin_ca/service.go new file mode 100644 index 00000000..85588c37 --- /dev/null +++ b/service/origin_ca/service.go @@ -0,0 +1,618 @@ +package originca + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "io" + "io/fs" + "net" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/ntp" + + "github.com/caddyserver/certmagic" +) + +const ( + cloudflareOriginCAEndpoint = "https://api.cloudflare.com/client/v4/certificates" + defaultRequestedValidity = option.CloudflareOriginCARequestValidity5475 + // min of 30 days and certmagic's 1/3 lifetime ratio (maintain.go) + defaultRenewBefore = 30 * 24 * time.Hour + // from certmagic retry backoff range (async.go) + minimumRenewRetryDelay = time.Minute + maximumRenewRetryDelay = time.Hour + storageLockPrefix = "cloudflare-origin-ca" +) + +func RegisterCertificateProvider(registry *certificate.Registry) { + certificate.Register[option.CloudflareOriginCACertificateProviderOptions](registry, C.TypeCloudflareOriginCA, NewCertificateProvider) +} + +var _ adapter.CertificateProviderService = (*Service)(nil) + +type Service struct { + certificate.Adapter + logger log.ContextLogger + ctx context.Context + cancel context.CancelFunc + done chan struct{} + timeFunc func() time.Time + httpClient *http.Client + storage certmagic.Storage + storageIssuerKey string + storageNamesKey string + storageLockKey string + apiToken string + originCAKey string + domain []string + requestType option.CloudflareOriginCARequestType + requestedValidity option.CloudflareOriginCARequestValidity + + access sync.RWMutex + currentCertificate *tls.Certificate + currentLeaf *x509.Certificate +} + +func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.CloudflareOriginCACertificateProviderOptions) (adapter.CertificateProviderService, error) { + domain, err := normalizeHostnames(options.Domain) + if err != nil { + return nil, err + } + if len(domain) == 0 { + return nil, E.New("missing domain") + } + apiToken := strings.TrimSpace(options.APIToken) + originCAKey := strings.TrimSpace(options.OriginCAKey) + switch { + case apiToken == "" && originCAKey == "": + return nil, E.New("api_token or origin_ca_key is required") + case apiToken != "" && originCAKey != "": + return nil, E.New("api_token and origin_ca_key are mutually exclusive") + } + requestType := options.RequestType + if requestType == "" { + requestType = option.CloudflareOriginCARequestTypeOriginRSA + } + requestedValidity := options.RequestedValidity + if requestedValidity == 0 { + requestedValidity = defaultRequestedValidity + } + ctx, cancel := context.WithCancel(ctx) + serviceDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: option.DialerOptions{ + Detour: options.Detour, + }, + RemoteIsDomain: true, + }) + if err != nil { + cancel() + return nil, E.Cause(err, "create Cloudflare Origin CA dialer") + } + var storage certmagic.Storage + if options.DataDirectory != "" { + storage = &certmagic.FileStorage{Path: options.DataDirectory} + } else { + storage = certmagic.Default.Storage + } + timeFunc := ntp.TimeFuncFromContext(ctx) + if timeFunc == nil { + timeFunc = time.Now + } + storageIssuerKey := C.TypeCloudflareOriginCA + "-" + string(requestType) + storageNamesKey := (&certmagic.CertificateResource{SANs: slices.Clone(domain)}).NamesKey() + storageLockKey := strings.Join([]string{ + storageLockPrefix, + certmagic.StorageKeys.Safe(storageIssuerKey), + certmagic.StorageKeys.Safe(storageNamesKey), + }, "/") + return &Service{ + Adapter: certificate.NewAdapter(C.TypeCloudflareOriginCA, tag), + logger: logger, + ctx: ctx, + cancel: cancel, + timeFunc: timeFunc, + httpClient: &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + RootCAs: adapter.RootPoolFromContext(ctx), + Time: timeFunc, + }, + ForceAttemptHTTP2: true, + }}, + storage: storage, + storageIssuerKey: storageIssuerKey, + storageNamesKey: storageNamesKey, + storageLockKey: storageLockKey, + apiToken: apiToken, + originCAKey: originCAKey, + domain: domain, + requestType: requestType, + requestedValidity: requestedValidity, + }, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + cachedCertificate, cachedLeaf, err := s.loadCachedCertificate() + if err != nil { + s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate")) + } else if cachedCertificate != nil { + s.setCurrentCertificate(cachedCertificate, cachedLeaf) + } + if cachedCertificate == nil { + err = s.issueAndStoreCertificate() + if err != nil { + return err + } + } else if s.shouldRenew(cachedLeaf, s.timeFunc()) { + err = s.issueAndStoreCertificate() + if err != nil { + s.logger.Warn(E.Cause(err, "renew cached Cloudflare Origin CA certificate")) + } + } + s.done = make(chan struct{}) + go s.refreshLoop() + return nil +} + +func (s *Service) Close() error { + s.cancel() + if done := s.done; done != nil { + <-done + } + if transport, loaded := s.httpClient.Transport.(*http.Transport); loaded { + transport.CloseIdleConnections() + } + return nil +} + +func (s *Service) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + s.access.RLock() + certificate := s.currentCertificate + s.access.RUnlock() + if certificate == nil { + return nil, E.New("Cloudflare Origin CA certificate is unavailable") + } + return certificate, nil +} + +func (s *Service) refreshLoop() { + defer close(s.done) + var retryDelay time.Duration + for { + waitDuration := retryDelay + if waitDuration == 0 { + s.access.RLock() + leaf := s.currentLeaf + s.access.RUnlock() + if leaf == nil { + waitDuration = minimumRenewRetryDelay + } else { + refreshAt := leaf.NotAfter.Add(-s.effectiveRenewBefore(leaf)) + waitDuration = refreshAt.Sub(s.timeFunc()) + if waitDuration < minimumRenewRetryDelay { + waitDuration = minimumRenewRetryDelay + } + } + } + timer := time.NewTimer(waitDuration) + select { + case <-s.ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + case <-timer.C: + } + err := s.issueAndStoreCertificate() + if err != nil { + s.logger.Error(E.Cause(err, "renew Cloudflare Origin CA certificate")) + s.access.RLock() + leaf := s.currentLeaf + s.access.RUnlock() + if leaf == nil { + retryDelay = minimumRenewRetryDelay + } else { + remaining := leaf.NotAfter.Sub(s.timeFunc()) + switch { + case remaining <= minimumRenewRetryDelay: + retryDelay = minimumRenewRetryDelay + case remaining < maximumRenewRetryDelay: + retryDelay = max(remaining/2, minimumRenewRetryDelay) + default: + retryDelay = maximumRenewRetryDelay + } + } + continue + } + retryDelay = 0 + } +} + +func (s *Service) shouldRenew(leaf *x509.Certificate, now time.Time) bool { + return !now.Before(leaf.NotAfter.Add(-s.effectiveRenewBefore(leaf))) +} + +func (s *Service) effectiveRenewBefore(leaf *x509.Certificate) time.Duration { + lifetime := leaf.NotAfter.Sub(leaf.NotBefore) + if lifetime <= 0 { + return 0 + } + return min(lifetime/3, defaultRenewBefore) +} + +func (s *Service) issueAndStoreCertificate() error { + err := s.storage.Lock(s.ctx, s.storageLockKey) + if err != nil { + return E.Cause(err, "lock Cloudflare Origin CA certificate storage") + } + defer func() { + err = s.storage.Unlock(context.WithoutCancel(s.ctx), s.storageLockKey) + if err != nil { + s.logger.Warn(E.Cause(err, "unlock Cloudflare Origin CA certificate storage")) + } + }() + cachedCertificate, cachedLeaf, err := s.loadCachedCertificate() + if err != nil { + s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate")) + } else if cachedCertificate != nil && !s.shouldRenew(cachedLeaf, s.timeFunc()) { + s.setCurrentCertificate(cachedCertificate, cachedLeaf) + return nil + } + certificatePEM, privateKeyPEM, tlsCertificate, leaf, err := s.requestCertificate(s.ctx) + if err != nil { + return err + } + issuerData, err := json.Marshal(originCAIssuerData{ + RequestType: s.requestType, + RequestedValidity: s.requestedValidity, + }) + if err != nil { + return E.Cause(err, "encode Cloudflare Origin CA certificate metadata") + } + err = storeCertificateResource(s.ctx, s.storage, s.storageIssuerKey, certmagic.CertificateResource{ + SANs: slices.Clone(s.domain), + CertificatePEM: certificatePEM, + PrivateKeyPEM: privateKeyPEM, + IssuerData: issuerData, + }) + if err != nil { + return E.Cause(err, "store Cloudflare Origin CA certificate") + } + s.setCurrentCertificate(tlsCertificate, leaf) + s.logger.Info("updated Cloudflare Origin CA certificate, expires at ", leaf.NotAfter.Format(time.RFC3339)) + return nil +} + +func (s *Service) requestCertificate(ctx context.Context) ([]byte, []byte, *tls.Certificate, *x509.Certificate, error) { + var privateKey crypto.Signer + switch s.requestType { + case option.CloudflareOriginCARequestTypeOriginRSA: + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, nil, nil, err + } + privateKey = rsaKey + case option.CloudflareOriginCARequestTypeOriginECC: + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, nil, nil, err + } + privateKey = ecKey + default: + return nil, nil, nil, nil, E.New("unsupported Cloudflare Origin CA request type: ", s.requestType) + } + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "encode private key") + } + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyDER, + }) + certificateRequestDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: s.domain[0]}, + DNSNames: s.domain, + }, privateKey) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "create certificate request") + } + certificateRequestPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: certificateRequestDER, + }) + requestBody, err := json.Marshal(originCARequest{ + CSR: string(certificateRequestPEM), + Hostnames: s.domain, + RequestType: string(s.requestType), + RequestedValidity: uint16(s.requestedValidity), + }) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "marshal request") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudflareOriginCAEndpoint, bytes.NewReader(requestBody)) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "create request") + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "sing-box/"+C.Version) + if s.apiToken != "" { + request.Header.Set("Authorization", "Bearer "+s.apiToken) + } else { + request.Header.Set("X-Auth-User-Service-Key", s.originCAKey) + } + response, err := s.httpClient.Do(request) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "request certificate from Cloudflare") + } + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "read Cloudflare response") + } + var responseEnvelope originCAResponse + err = json.Unmarshal(responseBody, &responseEnvelope) + if err != nil && response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil, nil, nil, nil, E.Cause(err, "decode Cloudflare response") + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, nil, nil, nil, buildOriginCAError(response.StatusCode, responseEnvelope.Errors, responseBody) + } + if !responseEnvelope.Success { + return nil, nil, nil, nil, buildOriginCAError(response.StatusCode, responseEnvelope.Errors, responseBody) + } + if responseEnvelope.Result.Certificate == "" { + return nil, nil, nil, nil, E.New("Cloudflare Origin CA response is missing certificate data") + } + certificatePEM := []byte(responseEnvelope.Result.Certificate) + tlsCertificate, leaf, err := parseKeyPair(certificatePEM, privateKeyPEM) + if err != nil { + return nil, nil, nil, nil, E.Cause(err, "parse issued certificate") + } + if !s.matchesCertificate(leaf) { + return nil, nil, nil, nil, E.New("issued Cloudflare Origin CA certificate does not match requested hostnames or key type") + } + return certificatePEM, privateKeyPEM, tlsCertificate, leaf, nil +} + +func (s *Service) loadCachedCertificate() (*tls.Certificate, *x509.Certificate, error) { + certificateResource, err := loadCertificateResource(s.ctx, s.storage, s.storageIssuerKey, s.storageNamesKey) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, nil + } + return nil, nil, err + } + tlsCertificate, leaf, err := parseKeyPair(certificateResource.CertificatePEM, certificateResource.PrivateKeyPEM) + if err != nil { + return nil, nil, E.Cause(err, "parse cached key pair") + } + if s.timeFunc().After(leaf.NotAfter) { + return nil, nil, nil + } + if !s.matchesCertificate(leaf) { + return nil, nil, nil + } + return tlsCertificate, leaf, nil +} + +func (s *Service) matchesCertificate(leaf *x509.Certificate) bool { + if leaf == nil { + return false + } + leafHostnames := leaf.DNSNames + if len(leafHostnames) == 0 && leaf.Subject.CommonName != "" { + leafHostnames = []string{leaf.Subject.CommonName} + } + normalizedLeafHostnames, err := normalizeHostnames(leafHostnames) + if err != nil { + return false + } + if !slices.Equal(normalizedLeafHostnames, s.domain) { + return false + } + switch s.requestType { + case option.CloudflareOriginCARequestTypeOriginRSA: + return leaf.PublicKeyAlgorithm == x509.RSA + case option.CloudflareOriginCARequestTypeOriginECC: + return leaf.PublicKeyAlgorithm == x509.ECDSA + default: + return false + } +} + +func (s *Service) setCurrentCertificate(certificate *tls.Certificate, leaf *x509.Certificate) { + s.access.Lock() + s.currentCertificate = certificate + s.currentLeaf = leaf + s.access.Unlock() +} + +func normalizeHostnames(hostnames []string) ([]string, error) { + normalizedHostnames := make([]string, 0, len(hostnames)) + seen := make(map[string]struct{}, len(hostnames)) + for _, hostname := range hostnames { + normalizedHostname := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(hostname, "."))) + if normalizedHostname == "" { + return nil, E.New("hostname is empty") + } + if net.ParseIP(normalizedHostname) != nil { + return nil, E.New("hostname cannot be an IP address: ", normalizedHostname) + } + if strings.Contains(normalizedHostname, "*") { + if !strings.HasPrefix(normalizedHostname, "*.") || strings.Count(normalizedHostname, "*") != 1 { + return nil, E.New("invalid wildcard hostname: ", normalizedHostname) + } + suffix := strings.TrimPrefix(normalizedHostname, "*.") + if strings.Count(suffix, ".") == 0 { + return nil, E.New("wildcard hostname must cover a multi-label domain: ", normalizedHostname) + } + normalizedHostname = "*." + suffix + } + if _, loaded := seen[normalizedHostname]; loaded { + continue + } + seen[normalizedHostname] = struct{}{} + normalizedHostnames = append(normalizedHostnames, normalizedHostname) + } + slices.Sort(normalizedHostnames) + return normalizedHostnames, nil +} + +func parseKeyPair(certificatePEM []byte, privateKeyPEM []byte) (*tls.Certificate, *x509.Certificate, error) { + keyPair, err := tls.X509KeyPair(certificatePEM, privateKeyPEM) + if err != nil { + return nil, nil, err + } + if len(keyPair.Certificate) == 0 { + return nil, nil, E.New("certificate chain is empty") + } + leaf, err := x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, nil, err + } + keyPair.Leaf = leaf + return &keyPair, leaf, nil +} + +func storeCertificateResource(ctx context.Context, storage certmagic.Storage, issuerKey string, certificateResource certmagic.CertificateResource) error { + metaBytes, err := json.MarshalIndent(certificateResource, "", "\t") + if err != nil { + return err + } + namesKey := certificateResource.NamesKey() + keyValueList := []struct { + key string + value []byte + }{ + { + key: certmagic.StorageKeys.SitePrivateKey(issuerKey, namesKey), + value: certificateResource.PrivateKeyPEM, + }, + { + key: certmagic.StorageKeys.SiteCert(issuerKey, namesKey), + value: certificateResource.CertificatePEM, + }, + { + key: certmagic.StorageKeys.SiteMeta(issuerKey, namesKey), + value: metaBytes, + }, + } + for i, item := range keyValueList { + err = storage.Store(ctx, item.key, item.value) + if err != nil { + for j := i - 1; j >= 0; j-- { + storage.Delete(ctx, keyValueList[j].key) + } + return err + } + } + return nil +} + +func loadCertificateResource(ctx context.Context, storage certmagic.Storage, issuerKey string, namesKey string) (certmagic.CertificateResource, error) { + privateKeyPEM, err := storage.Load(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, namesKey)) + if err != nil { + return certmagic.CertificateResource{}, err + } + certificatePEM, err := storage.Load(ctx, certmagic.StorageKeys.SiteCert(issuerKey, namesKey)) + if err != nil { + return certmagic.CertificateResource{}, err + } + metaBytes, err := storage.Load(ctx, certmagic.StorageKeys.SiteMeta(issuerKey, namesKey)) + if err != nil { + return certmagic.CertificateResource{}, err + } + var certificateResource certmagic.CertificateResource + err = json.Unmarshal(metaBytes, &certificateResource) + if err != nil { + return certmagic.CertificateResource{}, E.Cause(err, "decode Cloudflare Origin CA certificate metadata") + } + certificateResource.PrivateKeyPEM = privateKeyPEM + certificateResource.CertificatePEM = certificatePEM + return certificateResource, nil +} + +func buildOriginCAError(statusCode int, responseErrors []originCAResponseError, responseBody []byte) error { + if len(responseErrors) > 0 { + messageList := make([]string, 0, len(responseErrors)) + for _, responseError := range responseErrors { + if responseError.Message == "" { + continue + } + if responseError.Code != 0 { + messageList = append(messageList, responseError.Message+" (code "+strconv.Itoa(responseError.Code)+")") + } else { + messageList = append(messageList, responseError.Message) + } + } + if len(messageList) > 0 { + return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode, " ", strings.Join(messageList, ", ")) + } + } + responseText := strings.TrimSpace(string(responseBody)) + if responseText == "" { + return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode) + } + return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode, " ", responseText) +} + +type originCARequest struct { + CSR string `json:"csr"` + Hostnames []string `json:"hostnames"` + RequestType string `json:"request_type"` + RequestedValidity uint16 `json:"requested_validity"` +} + +type originCAResponse struct { + Success bool `json:"success"` + Errors []originCAResponseError `json:"errors"` + Result originCAResponseResult `json:"result"` +} + +type originCAResponseError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type originCAResponseResult struct { + Certificate string `json:"certificate"` +} + +type originCAIssuerData struct { + RequestType option.CloudflareOriginCARequestType `json:"request_type,omitempty"` + RequestedValidity option.CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"` +} diff --git a/service/resolved/resolve1.go b/service/resolved/resolve1.go new file mode 100644 index 00000000..ed1ee41a --- /dev/null +++ b/service/resolved/resolve1.go @@ -0,0 +1,656 @@ +//go:build linux + +package resolved + +import ( + "context" + "errors" + "fmt" + "net/netip" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + + "github.com/godbus/dbus/v5" + mDNS "github.com/miekg/dns" +) + +type resolve1Manager Service + +type Address struct { + IfIndex int32 + Family int32 + Address []byte +} + +type Name struct { + IfIndex int32 + Hostname string +} + +type ResourceRecord struct { + IfIndex int32 + Type uint16 + Class uint16 + Data []byte +} + +type SRVRecord struct { + Priority uint16 + Weight uint16 + Port uint16 + Hostname string + Addresses []Address + CNAME string +} + +type TXTRecord []byte + +type LinkDNS struct { + Family int32 + Address []byte +} + +type LinkDNSEx struct { + Family int32 + Address []byte + Port uint16 + Name string +} + +type LinkDomain struct { + Domain string + RoutingOnly bool +} + +func (t *resolve1Manager) getLink(ifIndex int32) (*TransportLink, *dbus.Error) { + link, loaded := t.links[ifIndex] + if !loaded { + link = &TransportLink{} + t.links[ifIndex] = link + iif, err := t.network.InterfaceFinder().ByIndex(int(ifIndex)) + if err != nil { + return nil, wrapError(err) + } + link.iif = iif + } + return link, nil +} + +func (t *resolve1Manager) getSenderProcess(sender dbus.Sender) (int32, error) { + var senderPid int32 + dbusObject := t.systemBus.Object("org.freedesktop.DBus", "/org/freedesktop/DBus") + if dbusObject == nil { + return 0, E.New("missing dbus object") + } + err := dbusObject.Call("org.freedesktop.DBus.GetConnectionUnixProcessID", 0, string(sender)).Store(&senderPid) + if err != nil { + return 0, E.Cause(err, "GetConnectionUnixProcessID") + } + return senderPid, nil +} + +func (t *resolve1Manager) createMetadata(sender dbus.Sender) adapter.InboundContext { + var metadata adapter.InboundContext + metadata.Inbound = t.Tag() + metadata.InboundType = C.TypeResolved + senderPid, err := t.getSenderProcess(sender) + if err != nil { + return metadata + } + var processInfo adapter.ConnectionOwner + metadata.ProcessInfo = &processInfo + processInfo.ProcessID = uint32(senderPid) + + processPath, err := os.Readlink(F.ToString("/proc/", senderPid, "/exe")) + if err == nil { + processInfo.ProcessPath = processPath + } else { + processPath, err = os.Readlink(F.ToString("/proc/", senderPid, "/comm")) + if err == nil { + processInfo.ProcessPath = processPath + } + } + + var uidFound bool + statusContent, err := os.ReadFile(F.ToString("/proc/", senderPid, "/status")) + if err == nil { + for _, line := range strings.Split(string(statusContent), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Uid:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + uid, parseErr := strconv.ParseUint(fields[1], 10, 32) + if parseErr != nil { + break + } + processInfo.UserId = int32(uid) + uidFound = true + if osUser, _ := user.LookupId(F.ToString(uid)); osUser != nil { + processInfo.UserName = osUser.Username + } + break + } + } + } + } + if !uidFound { + metadata.ProcessInfo.UserId = -1 + } + return metadata +} + +func (t *resolve1Manager) log(sender dbus.Sender, message ...any) { + metadata := t.createMetadata(sender) + if metadata.ProcessInfo != nil { + var prefix string + if metadata.ProcessInfo.ProcessPath != "" { + prefix = filepath.Base(metadata.ProcessInfo.ProcessPath) + } else if metadata.ProcessInfo.UserName != "" { + prefix = F.ToString("user:", metadata.ProcessInfo.UserName) + } else if metadata.ProcessInfo.UserId != 0 { + prefix = F.ToString("uid:", metadata.ProcessInfo.UserId) + } + t.logger.Info("(", prefix, ") ", F.ToString(message...)) + } else { + t.logger.Info(F.ToString(message...)) + } +} + +func (t *resolve1Manager) logRequest(sender dbus.Sender, message ...any) context.Context { + ctx := log.ContextWithNewID(t.ctx) + metadata := t.createMetadata(sender) + if metadata.ProcessInfo != nil { + var prefix string + if metadata.ProcessInfo.ProcessPath != "" { + prefix = filepath.Base(metadata.ProcessInfo.ProcessPath) + } else if metadata.ProcessInfo.UserName != "" { + prefix = F.ToString("user:", metadata.ProcessInfo.UserName) + } else if metadata.ProcessInfo.UserId != 0 { + prefix = F.ToString("uid:", metadata.ProcessInfo.UserId) + } + t.logger.InfoContext(ctx, "(", prefix, ") ", strings.Join(F.MapToString(message), " ")) + } else { + t.logger.InfoContext(ctx, strings.Join(F.MapToString(message), " ")) + } + return adapter.WithContext(ctx, &metadata) +} + +func familyToString(family int32) string { + switch family { + case syscall.AF_UNSPEC: + return "AF_UNSPEC" + case syscall.AF_INET: + return "AF_INET" + case syscall.AF_INET6: + return "AF_INET6" + default: + return F.ToString(family) + } +} + +func (t *resolve1Manager) ResolveHostname(sender dbus.Sender, ifIndex int32, hostname string, family int32, flags uint64) (addresses []Address, canonical string, outflags uint64, err *dbus.Error) { + t.linkAccess.Lock() + link, err := t.getLink(ifIndex) + if err != nil { + return + } + t.linkAccess.Unlock() + var strategy C.DomainStrategy + switch family { + case syscall.AF_UNSPEC: + strategy = C.DomainStrategyAsIS + case syscall.AF_INET: + strategy = C.DomainStrategyIPv4Only + case syscall.AF_INET6: + strategy = C.DomainStrategyIPv6Only + } + ctx := t.logRequest(sender, "ResolveHostname ", link.iif.Name, " ", hostname, " ", familyToString(family), " ", flags) + responseAddresses, lookupErr := t.dnsRouter.Lookup(ctx, hostname, adapter.DNSQueryOptions{ + LookupStrategy: strategy, + }) + if lookupErr != nil { + err = wrapError(err) + return + } + addresses = common.Map(responseAddresses, func(it netip.Addr) Address { + var addrFamily int32 + if it.Is4() { + addrFamily = syscall.AF_INET + } else { + addrFamily = syscall.AF_INET6 + } + return Address{ + IfIndex: ifIndex, + Family: addrFamily, + Address: it.AsSlice(), + } + }) + canonical = mDNS.CanonicalName(hostname) + return +} + +func (t *resolve1Manager) ResolveAddress(sender dbus.Sender, ifIndex int32, family int32, address []byte, flags uint64) (names []Name, outflags uint64, err *dbus.Error) { + t.linkAccess.Lock() + link, err := t.getLink(ifIndex) + if err != nil { + return + } + t.linkAccess.Unlock() + addr, ok := netip.AddrFromSlice(address) + if !ok { + err = wrapError(E.New("invalid address")) + return + } + var nibbles []string + for i := len(address) - 1; i >= 0; i-- { + b := address[i] + nibbles = append(nibbles, fmt.Sprintf("%x", b&0x0F)) + nibbles = append(nibbles, fmt.Sprintf("%x", b>>4)) + } + var ptrDomain string + if addr.Is4() { + ptrDomain = strings.Join(nibbles, ".") + ".in-addr.arpa." + } else { + ptrDomain = strings.Join(nibbles, ".") + ".ip6.arpa." + } + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{ + { + Name: mDNS.Fqdn(ptrDomain), + Qtype: mDNS.TypePTR, + Qclass: mDNS.ClassINET, + }, + }, + } + ctx := t.logRequest(sender, "ResolveAddress ", link.iif.Name, familyToString(family), addr, flags) + var metadata adapter.InboundContext + metadata.InboundType = t.Type() + metadata.Inbound = t.Tag() + response, lookupErr := t.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), request, adapter.DNSQueryOptions{}) + if lookupErr != nil { + err = wrapError(err) + return + } + if response.Rcode != mDNS.RcodeSuccess { + err = rcodeError(response.Rcode) + return + } + for _, rawRR := range response.Answer { + switch rr := rawRR.(type) { + case *mDNS.PTR: + names = append(names, Name{ + IfIndex: ifIndex, + Hostname: rr.Ptr, + }) + } + } + return +} + +func (t *resolve1Manager) ResolveRecord(sender dbus.Sender, ifIndex int32, hostname string, qClass uint16, qType uint16, flags uint64) (records []ResourceRecord, outflags uint64, err *dbus.Error) { + t.linkAccess.Lock() + link, err := t.getLink(ifIndex) + if err != nil { + return + } + t.linkAccess.Unlock() + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{ + { + Name: mDNS.Fqdn(hostname), + Qtype: qType, + Qclass: qClass, + }, + }, + } + ctx := t.logRequest(sender, "ResolveRecord", link.iif.Name, hostname, mDNS.Class(qClass), mDNS.Type(qType), flags) + var metadata adapter.InboundContext + metadata.InboundType = t.Type() + metadata.Inbound = t.Tag() + response, exchangeErr := t.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), request, adapter.DNSQueryOptions{}) + if exchangeErr != nil { + err = wrapError(exchangeErr) + return + } + if response.Rcode != mDNS.RcodeSuccess { + err = rcodeError(response.Rcode) + return + } + for _, rr := range response.Answer { + var record ResourceRecord + record.IfIndex = ifIndex + record.Type = rr.Header().Rrtype + record.Class = rr.Header().Class + data := make([]byte, mDNS.Len(rr)) + _, unpackErr := mDNS.PackRR(rr, data, 0, nil, false) + if unpackErr != nil { + err = wrapError(unpackErr) + } + record.Data = data + records = append(records, record) + } + return +} + +func (t *resolve1Manager) ResolveService(sender dbus.Sender, ifIndex int32, hostname string, sType string, domain string, family int32, flags uint64) (srvData []SRVRecord, txtData []TXTRecord, canonicalName string, canonicalType string, canonicalDomain string, outflags uint64, err *dbus.Error) { + t.linkAccess.Lock() + link, err := t.getLink(ifIndex) + if err != nil { + return + } + t.linkAccess.Unlock() + + serviceName := hostname + if hostname != "" && !strings.HasSuffix(hostname, ".") { + serviceName += "." + } + serviceName += sType + if !strings.HasSuffix(serviceName, ".") { + serviceName += "." + } + serviceName += domain + if !strings.HasSuffix(serviceName, ".") { + serviceName += "." + } + + ctx := t.logRequest(sender, "ResolveService ", link.iif.Name, " ", hostname, " ", sType, " ", domain, " ", familyToString(family), " ", flags) + + srvRequest := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{ + { + Name: serviceName, + Qtype: mDNS.TypeSRV, + Qclass: mDNS.ClassINET, + }, + }, + } + var metadata adapter.InboundContext + metadata.InboundType = t.Type() + metadata.Inbound = t.Tag() + srvResponse, exchangeErr := t.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), srvRequest, adapter.DNSQueryOptions{}) + if exchangeErr != nil { + err = wrapError(exchangeErr) + return + } + if srvResponse.Rcode != mDNS.RcodeSuccess { + err = rcodeError(srvResponse.Rcode) + return + } + + txtRequest := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{ + { + Name: serviceName, + Qtype: mDNS.TypeTXT, + Qclass: mDNS.ClassINET, + }, + }, + } + + txtResponse, exchangeErr := t.dnsRouter.Exchange(ctx, txtRequest, adapter.DNSQueryOptions{}) + if exchangeErr != nil { + err = wrapError(exchangeErr) + return + } + + for _, rawRR := range srvResponse.Answer { + switch rr := rawRR.(type) { + case *mDNS.SRV: + var srvRecord SRVRecord + srvRecord.Priority = rr.Priority + srvRecord.Weight = rr.Weight + srvRecord.Port = rr.Port + srvRecord.Hostname = rr.Target + + var strategy C.DomainStrategy + switch family { + case syscall.AF_UNSPEC: + strategy = C.DomainStrategyAsIS + case syscall.AF_INET: + strategy = C.DomainStrategyIPv4Only + case syscall.AF_INET6: + strategy = C.DomainStrategyIPv6Only + } + + addrs, lookupErr := t.dnsRouter.Lookup(ctx, rr.Target, adapter.DNSQueryOptions{ + LookupStrategy: strategy, + }) + if lookupErr == nil { + srvRecord.Addresses = common.Map(addrs, func(it netip.Addr) Address { + var addrFamily int32 + if it.Is4() { + addrFamily = syscall.AF_INET + } else { + addrFamily = syscall.AF_INET6 + } + return Address{ + IfIndex: ifIndex, + Family: addrFamily, + Address: it.AsSlice(), + } + }) + } + for _, a := range srvResponse.Answer { + if cname, ok := a.(*mDNS.CNAME); ok && cname.Header().Name == rr.Target { + srvRecord.CNAME = cname.Target + break + } + } + srvData = append(srvData, srvRecord) + } + } + for _, rawRR := range txtResponse.Answer { + switch rr := rawRR.(type) { + case *mDNS.TXT: + data := make([]byte, mDNS.Len(rr)) + _, packErr := mDNS.PackRR(rr, data, 0, nil, false) + if packErr == nil { + txtData = append(txtData, data) + } + } + } + canonicalName = mDNS.CanonicalName(hostname) + canonicalType = mDNS.CanonicalName(sType) + canonicalDomain = mDNS.CanonicalName(domain) + return +} + +func (t *resolve1Manager) SetLinkDNS(sender dbus.Sender, ifIndex int32, addresses []LinkDNS) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return wrapError(err) + } + link.address = addresses + if len(addresses) > 0 { + t.log(sender, "SetLinkDNS ", link.iif.Name, " ", strings.Join(common.Map(addresses, func(it LinkDNS) string { + return M.AddrFromIP(it.Address).String() + }), ", ")) + } else { + t.log(sender, "SetLinkDNS ", link.iif.Name, " (empty)") + } + return t.postUpdate(link) +} + +func (t *resolve1Manager) SetLinkDNSEx(sender dbus.Sender, ifIndex int32, addresses []LinkDNSEx) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return wrapError(err) + } + link.addressEx = addresses + if len(addresses) > 0 { + t.log(sender, "SetLinkDNSEx ", link.iif.Name, " ", strings.Join(common.Map(addresses, func(it LinkDNSEx) string { + return M.SocksaddrFrom(M.AddrFromIP(it.Address), it.Port).String() + }), ", ")) + } else { + t.log(sender, "SetLinkDNSEx ", link.iif.Name, " (empty)") + } + return t.postUpdate(link) +} + +func (t *resolve1Manager) SetLinkDomains(sender dbus.Sender, ifIndex int32, domains []LinkDomain) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return wrapError(err) + } + link.domain = domains + if len(domains) > 0 { + t.log(sender, "SetLinkDomains ", link.iif.Name, " ", strings.Join(common.Map(domains, func(domain LinkDomain) string { + if !domain.RoutingOnly { + return domain.Domain + } else { + return "~" + domain.Domain + } + }), ", ")) + } else { + t.log(sender, "SetLinkDomains ", link.iif.Name, " (empty)") + } + return t.postUpdate(link) +} + +func (t *resolve1Manager) SetLinkDefaultRoute(sender dbus.Sender, ifIndex int32, defaultRoute bool) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return err + } + link.defaultRoute = defaultRoute + if defaultRoute { + t.defaultRouteSequence = append(common.Filter(t.defaultRouteSequence, func(it int32) bool { return it != ifIndex }), ifIndex) + } else { + t.defaultRouteSequence = common.Filter(t.defaultRouteSequence, func(it int32) bool { return it != ifIndex }) + } + var defaultRouteString string + if defaultRoute { + defaultRouteString = "yes" + } else { + defaultRouteString = "no" + } + t.log(sender, "SetLinkDefaultRoute ", link.iif.Name, " ", defaultRouteString) + return t.postUpdate(link) +} + +func (t *resolve1Manager) SetLinkLLMNR(ifIndex int32, llmnrMode string) *dbus.Error { + return nil +} + +func (t *resolve1Manager) SetLinkMulticastDNS(ifIndex int32, mdnsMode string) *dbus.Error { + return nil +} + +func (t *resolve1Manager) SetLinkDNSOverTLS(sender dbus.Sender, ifIndex int32, dotMode string) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return wrapError(err) + } + switch dotMode { + case "yes": + link.dnsOverTLS = true + case "": + dotMode = "no" + fallthrough + case "opportunistic", "no": + link.dnsOverTLS = false + } + t.log(sender, "SetLinkDNSOverTLS ", link.iif.Name, " ", dotMode) + return t.postUpdate(link) +} + +func (t *resolve1Manager) SetLinkDNSSEC(ifIndex int32, dnssecMode string) *dbus.Error { + return nil +} + +func (t *resolve1Manager) SetLinkDNSSECNegativeTrustAnchors(ifIndex int32, domains []string) *dbus.Error { + return nil +} + +func (t *resolve1Manager) RevertLink(sender dbus.Sender, ifIndex int32) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return wrapError(err) + } + delete(t.links, ifIndex) + t.log(sender, "RevertLink ", link.iif.Name) + return t.postUpdate(link) +} + +// TODO: implement RegisterService, UnregisterService + +func (t *resolve1Manager) RegisterService(sender dbus.Sender, identifier string, nameTemplate string, serviceType string, port uint16, priority uint16, weight uint16, txtRecords []TXTRecord) (objectPath dbus.ObjectPath, dbusErr *dbus.Error) { + return "", wrapError(E.New("not implemented")) +} + +func (t *resolve1Manager) UnregisterService(sender dbus.Sender, servicePath dbus.ObjectPath) error { + return wrapError(E.New("not implemented")) +} + +func (t *resolve1Manager) ResetStatistics() *dbus.Error { + return nil +} + +func (t *resolve1Manager) FlushCaches(sender dbus.Sender) *dbus.Error { + t.dnsRouter.ClearCache() + t.log(sender, "FlushCaches") + return nil +} + +func (t *resolve1Manager) ResetServerFeatures() *dbus.Error { + return nil +} + +func (t *resolve1Manager) postUpdate(link *TransportLink) *dbus.Error { + if t.updateCallback != nil { + return wrapError(t.updateCallback(link)) + } + return nil +} + +func rcodeError(rcode int) *dbus.Error { + return dbus.NewError("org.freedesktop.resolve1.DnsError."+mDNS.RcodeToString[rcode], []any{mDNS.RcodeToString[rcode]}) +} + +func wrapError(err error) *dbus.Error { + if err == nil { + return nil + } + var rcode dns.RcodeError + if errors.As(err, &rcode) { + return rcodeError(int(rcode)) + } + return dbus.MakeFailedError(err) +} diff --git a/service/resolved/service.go b/service/resolved/service.go new file mode 100644 index 00000000..eaedc09d --- /dev/null +++ b/service/resolved/service.go @@ -0,0 +1,254 @@ +//go:build linux + +package resolved + +import ( + "context" + "net" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + dnsOutbound "github.com/sagernet/sing-box/protocol/dns" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + "github.com/godbus/dbus/v5" + mDNS "github.com/miekg/dns" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.ResolvedServiceOptions](registry, C.TypeResolved, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + network adapter.NetworkManager + dnsRouter adapter.DNSRouter + listener *listener.Listener + systemBus *dbus.Conn + linkAccess sync.RWMutex + links map[int32]*TransportLink + defaultRouteSequence []int32 + networkUpdateCallback *list.Element[tun.NetworkUpdateCallback] + updateCallback func(*TransportLink) error + deleteCallback func(*TransportLink) +} + +type TransportLink struct { + iif *control.Interface + address []LinkDNS + addressEx []LinkDNSEx + domain []LinkDomain + defaultRoute bool + dnsOverTLS bool + // dnsOverTLSFallback bool +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ResolvedServiceOptions) (adapter.Service, error) { + inbound := &Service{ + Adapter: boxService.NewAdapter(C.TypeResolved, tag), + ctx: ctx, + logger: logger, + network: service.FromContext[adapter.NetworkManager](ctx), + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + links: make(map[int32]*TransportLink), + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP, N.NetworkUDP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + OOBPacketHandler: inbound, + ThreadUnsafePacketWriter: true, + }) + return inbound, nil +} + +func (i *Service) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateInitialize: + inboundManager := service.FromContext[adapter.ServiceManager](i.ctx) + for _, transport := range inboundManager.Services() { + if transport.Type() == C.TypeResolved && transport != i { + return E.New("multiple resolved service are not supported") + } + } + systemBus, err := dbus.SystemBus() + if err != nil { + return err + } + i.systemBus = systemBus + err = systemBus.Export((*resolve1Manager)(i), "/org/freedesktop/resolve1", "org.freedesktop.resolve1.Manager") + if err != nil { + return err + } + reply, err := systemBus.RequestName("org.freedesktop.resolve1", dbus.NameFlagDoNotQueue) + if err != nil { + return err + } + switch reply { + case dbus.RequestNameReplyPrimaryOwner: + case dbus.RequestNameReplyExists: + return E.New("D-Bus object already exists, maybe real resolved is running") + default: + return E.New("unknown request name reply: ", reply) + } + i.networkUpdateCallback = i.network.NetworkMonitor().RegisterCallback(i.onNetworkUpdate) + case adapter.StartStateStart: + err := i.listener.Start() + if err != nil { + return err + } + } + return nil +} + +func (i *Service) Close() error { + if i.networkUpdateCallback != nil { + i.network.NetworkMonitor().UnregisterCallback(i.networkUpdateCallback) + } + if i.systemBus != nil { + i.systemBus.ReleaseName("org.freedesktop.resolve1") + i.systemBus.Close() + } + return i.listener.Close() +} + +func (i *Service) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = i.Tag() + metadata.InboundType = i.Type() + metadata.Destination = M.Socksaddr{} + for { + conn.SetReadDeadline(time.Now().Add(C.DNSTimeout)) + err := dnsOutbound.HandleStreamDNSRequest(ctx, i.dnsRouter, conn, metadata) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + return + } + } +} + +func (i *Service) NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr) { + go i.exchangePacket(buffer, oob, source) +} + +func (i *Service) exchangePacket(buffer *buf.Buffer, oob []byte, source M.Socksaddr) { + ctx := log.ContextWithNewID(i.ctx) + err := i.exchangePacket0(ctx, buffer, oob, source) + if err != nil { + i.logger.ErrorContext(ctx, "process DNS packet: ", err) + } +} + +func (i *Service) exchangePacket0(ctx context.Context, buffer *buf.Buffer, oob []byte, source M.Socksaddr) error { + var message mDNS.Msg + err := message.Unpack(buffer.Bytes()) + buffer.Release() + if err != nil { + return E.Cause(err, "unpack request") + } + var metadata adapter.InboundContext + metadata.Source = source + metadata.InboundType = i.Type() + metadata.Inbound = i.Tag() + response, err := i.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), &message, adapter.DNSQueryOptions{}) + if err != nil { + return err + } + responseBuffer, err := dns.TruncateDNSMessage(&message, response, 0) + if err != nil { + return err + } + defer responseBuffer.Release() + _, _, err = i.listener.UDPConn().WriteMsgUDPAddrPort(responseBuffer.Bytes(), oob, source.AddrPort()) + return err +} + +func (i *Service) onNetworkUpdate() { + i.linkAccess.Lock() + defer i.linkAccess.Unlock() + var deleteIfIndex []int + for ifIndex, link := range i.links { + iif, err := i.network.InterfaceFinder().ByIndex(int(ifIndex)) + if err != nil || iif != link.iif { + deleteIfIndex = append(deleteIfIndex, int(ifIndex)) + } + i.defaultRouteSequence = common.Filter(i.defaultRouteSequence, func(it int32) bool { + return it != ifIndex + }) + if i.deleteCallback != nil { + i.deleteCallback(link) + } + } + for _, ifIndex := range deleteIfIndex { + delete(i.links, int32(ifIndex)) + } +} + +func (conf *TransportLink) nameList(ndots int, name string) []string { + search := common.Map(common.Filter(conf.domain, func(it LinkDomain) bool { + return !it.RoutingOnly + }), func(it LinkDomain) string { + return it.Domain + }) + + l := len(name) + rooted := l > 0 && name[l-1] == '.' + if l > 254 || l == 254 && !rooted { + return nil + } + + if rooted { + if avoidDNS(name) { + return nil + } + return []string{name} + } + + hasNdots := strings.Count(name, ".") >= ndots + name += "." + // l++ + + names := make([]string, 0, 1+len(search)) + if hasNdots && !avoidDNS(name) { + names = append(names, name) + } + for _, suffix := range search { + fqdn := name + suffix + if !avoidDNS(fqdn) && len(fqdn) <= 254 { + names = append(names, fqdn) + } + } + if !hasNdots && !avoidDNS(name) { + names = append(names, name) + } + return names +} + +func avoidDNS(name string) bool { + if name == "" { + return true + } + if name[len(name)-1] == '.' { + name = name[:len(name)-1] + } + return strings.HasSuffix(name, ".onion") +} diff --git a/service/resolved/stub.go b/service/resolved/stub.go new file mode 100644 index 00000000..ede64691 --- /dev/null +++ b/service/resolved/stub.go @@ -0,0 +1,27 @@ +//go:build !linux + +package resolved + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.ResolvedServiceOptions](registry, C.TypeResolved, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ResolvedServiceOptions) (adapter.Service, error) { + return nil, E.New("resolved service is only supported on Linux") + }) +} + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.ResolvedDNSServerOptions](registry, C.TypeResolved, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ResolvedDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New("resolved DNS server is only supported on Linux") + }) +} diff --git a/service/resolved/transport.go b/service/resolved/transport.go new file mode 100644 index 00000000..ac20663a --- /dev/null +++ b/service/resolved/transport.go @@ -0,0 +1,307 @@ +//go:build linux + +package resolved + +import ( + "context" + "net/netip" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.ResolvedDNSServerOptions](registry, C.TypeResolved, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + logger logger.ContextLogger + serviceTag string + acceptDefaultResolvers bool + ndots int + timeout time.Duration + attempts int + rotate bool + service *Service + linkAccess sync.RWMutex + linkServers map[*TransportLink]*LinkServers +} + +type LinkServers struct { + Link *TransportLink + Servers []adapter.DNSTransport + serverOffset uint32 +} + +func (c *LinkServers) ServerOffset(rotate bool) uint32 { + if rotate { + return atomic.AddUint32(&c.serverOffset, 1) - 1 + } + return 0 +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.ResolvedDNSServerOptions) (adapter.DNSTransport, error) { + return &Transport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeDHCP, tag, nil), + ctx: ctx, + logger: logger, + serviceTag: options.Service, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + // ndots: options.NDots, + // timeout: time.Duration(options.Timeout), + // attempts: options.Attempts, + // rotate: options.Rotate, + ndots: 1, + timeout: 5 * time.Second, + attempts: 2, + linkServers: make(map[*TransportLink]*LinkServers), + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + serviceManager := service.FromContext[adapter.ServiceManager](t.ctx) + service, loaded := serviceManager.Get(t.serviceTag) + if !loaded { + return E.New("service not found: ", t.serviceTag) + } + resolvedInbound, isResolved := service.(*Service) + if !isResolved { + return E.New("service is not resolved: ", t.serviceTag) + } + resolvedInbound.updateCallback = t.updateTransports + resolvedInbound.deleteCallback = t.deleteTransport + t.service = resolvedInbound + return nil +} + +func (t *Transport) Close() error { + t.linkAccess.RLock() + defer t.linkAccess.RUnlock() + for _, servers := range t.linkServers { + for _, server := range servers.Servers { + server.Close() + } + } + return nil +} + +func (t *Transport) Reset() { + t.linkAccess.RLock() + defer t.linkAccess.RUnlock() + for _, servers := range t.linkServers { + for _, server := range servers.Servers { + server.Reset() + } + } +} + +func (t *Transport) updateTransports(link *TransportLink) error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + if servers, loaded := t.linkServers[link]; loaded { + for _, server := range servers.Servers { + server.Close() + } + } + serverDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{ + BindInterface: link.iif.Name, + UDPFragmentDefault: true, + })) + var transports []adapter.DNSTransport + for _, address := range link.address { + serverAddr, ok := netip.AddrFromSlice(address.Address) + if !ok { + return os.ErrInvalid + } + if link.dnsOverTLS { + tlsConfig := common.Must1(tls.NewClient(t.ctx, t.logger, serverAddr.String(), option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverAddr.String(), + })) + transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53), tlsConfig)) + + } else { + transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53))) + } + } + for _, address := range link.addressEx { + serverAddr, ok := netip.AddrFromSlice(address.Address) + if !ok { + return os.ErrInvalid + } + if link.dnsOverTLS { + var serverName string + if address.Name != "" { + serverName = address.Name + } else { + serverName = serverAddr.String() + } + tlsConfig := common.Must1(tls.NewClient(t.ctx, t.logger, serverAddr.String(), option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + })) + transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port), tlsConfig)) + + } else { + transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port))) + } + } + t.linkServers[link] = &LinkServers{ + Link: link, + Servers: transports, + } + return nil +} + +func (t *Transport) deleteTransport(link *TransportLink) { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + servers, loaded := t.linkServers[link] + if !loaded { + return + } + for _, server := range servers.Servers { + server.Close() + } + delete(t.linkServers, link) +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + var selectedLink *TransportLink + t.service.linkAccess.RLock() + for _, link := range t.service.links { + for _, domain := range link.domain { + if domain.Domain == "." && domain.RoutingOnly && !t.acceptDefaultResolvers { + continue + } + if strings.HasSuffix(question.Name, domain.Domain) { + selectedLink = link + } + } + } + if selectedLink == nil && t.acceptDefaultResolvers { + for l := len(t.service.defaultRouteSequence); l > 0; l-- { + selectedLink = t.service.links[t.service.defaultRouteSequence[l-1]] + if len(selectedLink.address) > 0 || len(selectedLink.addressEx) > 0 { + break + } + } + } + t.service.linkAccess.RUnlock() + if selectedLink == nil { + return dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil + } + t.linkAccess.RLock() + servers := t.linkServers[selectedLink] + t.linkAccess.RUnlock() + if len(servers.Servers) == 0 { + return dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil + } + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { + return t.exchangeParallel(ctx, servers, message) + } else { + return t.exchangeSingleRequest(ctx, servers, message) + } +} + +func (t *Transport) exchangeSingleRequest(ctx context.Context, servers *LinkServers, message *mDNS.Msg) (*mDNS.Msg, error) { + var lastErr error + for _, fqdn := range servers.Link.nameList(t.ndots, message.Question[0].Name) { + response, err := t.tryOneName(ctx, servers, message, fqdn) + if err != nil { + lastErr = err + continue + } + return response, nil + } + return nil, lastErr +} + +func (t *Transport) tryOneName(ctx context.Context, servers *LinkServers, message *mDNS.Msg, fqdn string) (*mDNS.Msg, error) { + serverOffset := servers.ServerOffset(t.rotate) + sLen := uint32(len(servers.Servers)) + var lastErr error + for i := 0; i < t.attempts; i++ { + for j := uint32(0); j < sLen; j++ { + server := servers.Servers[(serverOffset+j)%sLen] + question := message.Question[0] + question.Name = fqdn + exchangeMessage := *message + exchangeMessage.Question = []mDNS.Question{question} + exchangeCtx, cancel := context.WithTimeout(ctx, t.timeout) + response, err := server.Exchange(exchangeCtx, &exchangeMessage) + cancel() + if err != nil { + lastErr = err + continue + } + return response, nil + } + } + return nil, E.Cause(lastErr, fqdn) +} + +func (t *Transport) exchangeParallel(ctx context.Context, servers *LinkServers, message *mDNS.Msg) (*mDNS.Msg, error) { + returned := make(chan struct{}) + defer close(returned) + type queryResult struct { + response *mDNS.Msg + err error + } + results := make(chan queryResult) + startRacer := func(ctx context.Context, fqdn string) { + response, err := t.tryOneName(ctx, servers, message, fqdn) + select { + case results <- queryResult{response, err}: + case <-returned: + } + } + queryCtx, queryCancel := context.WithCancel(ctx) + defer queryCancel() + var nameCount int + for _, fqdn := range servers.Link.nameList(t.ndots, message.Question[0].Name) { + nameCount++ + go startRacer(queryCtx, fqdn) + } + var errors []error + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-results: + if result.err == nil { + return result.response, nil + } + errors = append(errors, result.err) + if len(errors) == nameCount { + return nil, E.Errors(errors...) + } + } + } +} diff --git a/service/ssmapi/api.go b/service/ssmapi/api.go new file mode 100644 index 00000000..6a067c50 --- /dev/null +++ b/service/ssmapi/api.go @@ -0,0 +1,177 @@ +package ssmapi + +import ( + "net/http" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/logger" + sHTTP "github.com/sagernet/sing/protocol/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type APIServer struct { + logger logger.Logger + traffic *TrafficManager + user *UserManager +} + +func NewAPIServer(logger logger.Logger, traffic *TrafficManager, user *UserManager) *APIServer { + return &APIServer{ + logger: logger, + traffic: traffic, + user: user, + } +} + +func (s *APIServer) Route(r chi.Router) { + r.Route("/server/v1", func(r chi.Router) { + r.Use(func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request)) + handler.ServeHTTP(writer, request) + }) + }) + r.Get("/", s.getServerInfo) + r.Get("/users", s.listUser) + r.Post("/users", s.addUser) + r.Get("/users/{username}", s.getUser) + r.Put("/users/{username}", s.updateUser) + r.Delete("/users/{username}", s.deleteUser) + r.Get("/stats", s.getStats) + }) +} + +func (s *APIServer) getServerInfo(writer http.ResponseWriter, request *http.Request) { + render.JSON(writer, request, render.M{ + "server": "sing-box " + C.Version, + "apiVersion": "v1", + }) +} + +type UserObject struct { + UserName string `json:"username"` + Password string `json:"uPSK,omitempty"` + DownlinkBytes int64 `json:"downlinkBytes"` + UplinkBytes int64 `json:"uplinkBytes"` + DownlinkPackets int64 `json:"downlinkPackets"` + UplinkPackets int64 `json:"uplinkPackets"` + TCPSessions int64 `json:"tcpSessions"` + UDPSessions int64 `json:"udpSessions"` +} + +func (s *APIServer) listUser(writer http.ResponseWriter, request *http.Request) { + render.JSON(writer, request, render.M{ + "users": s.user.List(), + }) +} + +func (s *APIServer) addUser(writer http.ResponseWriter, request *http.Request) { + var addRequest struct { + UserName string `json:"username"` + Password string `json:"uPSK"` + } + err := render.DecodeJSON(request.Body, &addRequest) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + err = s.user.Add(addRequest.UserName, addRequest.Password) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusCreated) +} + +func (s *APIServer) getUser(writer http.ResponseWriter, request *http.Request) { + userName := chi.URLParam(request, "username") + if userName == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + uPSK, loaded := s.user.Get(userName) + if !loaded { + writer.WriteHeader(http.StatusNotFound) + return + } + user := UserObject{ + UserName: userName, + Password: uPSK, + } + s.traffic.ReadUser(&user) + render.JSON(writer, request, user) +} + +func (s *APIServer) updateUser(writer http.ResponseWriter, request *http.Request) { + userName := chi.URLParam(request, "username") + if userName == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + var updateRequest struct { + Password string `json:"uPSK"` + } + err := render.DecodeJSON(request.Body, &updateRequest) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + _, loaded := s.user.Get(userName) + if !loaded { + writer.WriteHeader(http.StatusNotFound) + return + } + err = s.user.Update(userName, updateRequest.Password) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusNoContent) +} + +func (s *APIServer) deleteUser(writer http.ResponseWriter, request *http.Request) { + userName := chi.URLParam(request, "username") + if userName == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + _, loaded := s.user.Get(userName) + if !loaded { + writer.WriteHeader(http.StatusNotFound) + return + } + err := s.user.Delete(userName) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusNoContent) +} + +func (s *APIServer) getStats(writer http.ResponseWriter, request *http.Request) { + requireClear := request.URL.Query().Get("clear") == "true" + + users := s.user.List() + s.traffic.ReadUsers(users, requireClear) + for i := range users { + users[i].Password = "" + } + uplinkBytes, downlinkBytes, uplinkPackets, downlinkPackets, tcpSessions, udpSessions := s.traffic.ReadGlobal(requireClear) + + render.JSON(writer, request, render.M{ + "uplinkBytes": uplinkBytes, + "downlinkBytes": downlinkBytes, + "uplinkPackets": uplinkPackets, + "downlinkPackets": downlinkPackets, + "tcpSessions": tcpSessions, + "udpSessions": udpSessions, + "users": users, + }) +} diff --git a/service/ssmapi/cache.go b/service/ssmapi/cache.go new file mode 100644 index 00000000..f942265d --- /dev/null +++ b/service/ssmapi/cache.go @@ -0,0 +1,239 @@ +package ssmapi + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "sync/atomic" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/service/filemanager" +) + +type Cache struct { + Endpoints *badjson.TypedMap[string, *EndpointCache] `json:"endpoints"` +} + +type EndpointCache struct { + GlobalUplink int64 `json:"global_uplink"` + GlobalDownlink int64 `json:"global_downlink"` + GlobalUplinkPackets int64 `json:"global_uplink_packets"` + GlobalDownlinkPackets int64 `json:"global_downlink_packets"` + GlobalTCPSessions int64 `json:"global_tcp_sessions"` + GlobalUDPSessions int64 `json:"global_udp_sessions"` + UserUplink *badjson.TypedMap[string, int64] `json:"user_uplink"` + UserDownlink *badjson.TypedMap[string, int64] `json:"user_downlink"` + UserUplinkPackets *badjson.TypedMap[string, int64] `json:"user_uplink_packets"` + UserDownlinkPackets *badjson.TypedMap[string, int64] `json:"user_downlink_packets"` + UserTCPSessions *badjson.TypedMap[string, int64] `json:"user_tcp_sessions"` + UserUDPSessions *badjson.TypedMap[string, int64] `json:"user_udp_sessions"` + Users *badjson.TypedMap[string, string] `json:"users"` +} + +func (s *Service) loadCache() error { + if s.cachePath == "" { + return nil + } + basePath := filemanager.BasePath(s.ctx, s.cachePath) + cacheBinary, err := os.ReadFile(basePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + err = s.decodeCache(cacheBinary) + if err != nil { + os.RemoveAll(basePath) + return err + } + s.cacheMutex.Lock() + s.lastSavedCache = cacheBinary + s.cacheMutex.Unlock() + return nil +} + +func (s *Service) saveCache() error { + if s.cachePath == "" { + return nil + } + cacheBinary, err := s.encodeCache() + if err != nil { + return err + } + s.cacheMutex.Lock() + defer s.cacheMutex.Unlock() + if bytes.Equal(s.lastSavedCache, cacheBinary) { + return nil + } + return s.writeCache(cacheBinary) +} + +func (s *Service) writeCache(cacheBinary []byte) error { + basePath := filemanager.BasePath(s.ctx, s.cachePath) + err := os.MkdirAll(filepath.Dir(basePath), 0o777) + if err != nil { + return err + } + err = os.WriteFile(basePath, cacheBinary, 0o644) + if err != nil { + return err + } + s.lastSavedCache = cacheBinary + return nil +} + +func (s *Service) decodeCache(cacheBinary []byte) error { + if len(cacheBinary) == 0 { + return nil + } + cache, err := json.UnmarshalExtended[*Cache](cacheBinary) + if err != nil { + return err + } + if cache.Endpoints == nil || cache.Endpoints.Size() == 0 { + return nil + } + for _, entry := range cache.Endpoints.Entries() { + trafficManager, loaded := s.traffics[entry.Key] + if !loaded { + continue + } + trafficManager.globalUplink.Store(entry.Value.GlobalUplink) + trafficManager.globalDownlink.Store(entry.Value.GlobalDownlink) + trafficManager.globalUplinkPackets.Store(entry.Value.GlobalUplinkPackets) + trafficManager.globalDownlinkPackets.Store(entry.Value.GlobalDownlinkPackets) + trafficManager.globalTCPSessions.Store(entry.Value.GlobalTCPSessions) + trafficManager.globalUDPSessions.Store(entry.Value.GlobalUDPSessions) + trafficManager.userUplink = typedAtomicInt64Map(entry.Value.UserUplink) + trafficManager.userDownlink = typedAtomicInt64Map(entry.Value.UserDownlink) + trafficManager.userUplinkPackets = typedAtomicInt64Map(entry.Value.UserUplinkPackets) + trafficManager.userDownlinkPackets = typedAtomicInt64Map(entry.Value.UserDownlinkPackets) + trafficManager.userTCPSessions = typedAtomicInt64Map(entry.Value.UserTCPSessions) + trafficManager.userUDPSessions = typedAtomicInt64Map(entry.Value.UserUDPSessions) + userManager, loaded := s.users[entry.Key] + if !loaded { + continue + } + userManager.usersMap = typedMap(entry.Value.Users) + _ = userManager.postUpdate(false) + } + return nil +} + +func (s *Service) encodeCache() ([]byte, error) { + endpoints := new(badjson.TypedMap[string, *EndpointCache]) + for tag, traffic := range s.traffics { + var ( + userUplink = new(badjson.TypedMap[string, int64]) + userDownlink = new(badjson.TypedMap[string, int64]) + userUplinkPackets = new(badjson.TypedMap[string, int64]) + userDownlinkPackets = new(badjson.TypedMap[string, int64]) + userTCPSessions = new(badjson.TypedMap[string, int64]) + userUDPSessions = new(badjson.TypedMap[string, int64]) + userMap = new(badjson.TypedMap[string, string]) + ) + for user, uplink := range traffic.userUplink { + if uplink.Load() > 0 { + userUplink.Put(user, uplink.Load()) + } + } + for user, downlink := range traffic.userDownlink { + if downlink.Load() > 0 { + userDownlink.Put(user, downlink.Load()) + } + } + for user, uplinkPackets := range traffic.userUplinkPackets { + if uplinkPackets.Load() > 0 { + userUplinkPackets.Put(user, uplinkPackets.Load()) + } + } + for user, downlinkPackets := range traffic.userDownlinkPackets { + if downlinkPackets.Load() > 0 { + userDownlinkPackets.Put(user, downlinkPackets.Load()) + } + } + for user, tcpSessions := range traffic.userTCPSessions { + if tcpSessions.Load() > 0 { + userTCPSessions.Put(user, tcpSessions.Load()) + } + } + for user, udpSessions := range traffic.userUDPSessions { + if udpSessions.Load() > 0 { + userUDPSessions.Put(user, udpSessions.Load()) + } + } + userManager := s.users[tag] + if userManager != nil && len(userManager.usersMap) > 0 { + userMap = new(badjson.TypedMap[string, string]) + for username, password := range userManager.usersMap { + if username != "" && password != "" { + userMap.Put(username, password) + } + } + } + endpoints.Put(tag, &EndpointCache{ + GlobalUplink: traffic.globalUplink.Load(), + GlobalDownlink: traffic.globalDownlink.Load(), + GlobalUplinkPackets: traffic.globalUplinkPackets.Load(), + GlobalDownlinkPackets: traffic.globalDownlinkPackets.Load(), + GlobalTCPSessions: traffic.globalTCPSessions.Load(), + GlobalUDPSessions: traffic.globalUDPSessions.Load(), + UserUplink: sortTypedMap(userUplink), + UserDownlink: sortTypedMap(userDownlink), + UserUplinkPackets: sortTypedMap(userUplinkPackets), + UserDownlinkPackets: sortTypedMap(userDownlinkPackets), + UserTCPSessions: sortTypedMap(userTCPSessions), + UserUDPSessions: sortTypedMap(userUDPSessions), + Users: sortTypedMap(userMap), + }) + } + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetIndent("", " ") + err := encoder.Encode(&Cache{ + Endpoints: sortTypedMap(endpoints), + }) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func sortTypedMap[T comparable](trafficMap *badjson.TypedMap[string, T]) *badjson.TypedMap[string, T] { + if trafficMap == nil { + return nil + } + keys := trafficMap.Keys() + sort.Strings(keys) + sortedMap := new(badjson.TypedMap[string, T]) + for _, key := range keys { + value, _ := trafficMap.Get(key) + sortedMap.Put(key, value) + } + return sortedMap +} + +func typedAtomicInt64Map(trafficMap *badjson.TypedMap[string, int64]) map[string]*atomic.Int64 { + result := make(map[string]*atomic.Int64) + if trafficMap != nil { + for _, entry := range trafficMap.Entries() { + counter := new(atomic.Int64) + counter.Store(entry.Value) + result[entry.Key] = counter + } + } + return result +} + +func typedMap[T comparable](trafficMap *badjson.TypedMap[string, T]) map[string]T { + result := make(map[string]T) + if trafficMap != nil { + for _, entry := range trafficMap.Entries() { + result[entry.Key] = entry.Value + } + } + return result +} diff --git a/service/ssmapi/server.go b/service/ssmapi/server.go new file mode 100644 index 00000000..157ea150 --- /dev/null +++ b/service/ssmapi/server.go @@ -0,0 +1,163 @@ +package ssmapi + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "golang.org/x/net/http2" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.SSMAPIServiceOptions](registry, C.TypeSSMAPI, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + httpServer *http.Server + traffics map[string]*TrafficManager + users map[string]*UserManager + cachePath string + saveTicker *time.Ticker + lastSavedCache []byte + cacheMutex sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.SSMAPIServiceOptions) (adapter.Service, error) { + ctx, cancel := context.WithCancel(ctx) + chiRouter := chi.NewRouter() + s := &Service{ + Adapter: boxService.NewAdapter(C.TypeSSMAPI, tag), + ctx: ctx, + cancel: cancel, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + httpServer: &http.Server{ + Handler: chiRouter, + }, + traffics: make(map[string]*TrafficManager), + users: make(map[string]*UserManager), + cachePath: options.CachePath, + } + inboundManager := service.FromContext[adapter.InboundManager](ctx) + if options.Servers.Size() == 0 { + return nil, E.New("missing servers") + } + for i, entry := range options.Servers.Entries() { + inbound, loaded := inboundManager.Get(entry.Value) + if !loaded { + return nil, E.New("parse SSM server[", i, "]: inbound ", entry.Value, " not found") + } + managedServer, isManaged := inbound.(adapter.ManagedSSMServer) + if !isManaged { + return nil, E.New("parse SSM server[", i, "]: inbound/", inbound.Type(), "[", inbound.Tag(), "] is not a SSM server") + } + traffic := NewTrafficManager() + managedServer.SetTracker(traffic) + user := NewUserManager(managedServer, traffic) + chiRouter.Route(entry.Key, NewAPIServer(logger, traffic, user).Route) + s.traffics[entry.Key] = traffic + s.users[entry.Key] = user + } + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + s.tlsConfig = tlsConfig + } + return s, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := s.loadCache() + if err != nil { + s.logger.Error(E.Cause(err, "load cache")) + } + s.saveTicker = time.NewTicker(1 * time.Minute) + go s.loopSaveCache() + if s.tlsConfig != nil { + err = s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + tcpListener, err := s.listener.ListenTCP() + if err != nil { + return err + } + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) + } + go func() { + err = s.httpServer.Serve(tcpListener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("serve error: ", err) + } + }() + return nil +} + +func (s *Service) loopSaveCache() { + for { + select { + case <-s.ctx.Done(): + return + case <-s.saveTicker.C: + err := s.saveCache() + if err != nil { + s.logger.Error(E.Cause(err, "save cache")) + } + } + } +} + +func (s *Service) Close() error { + if s.cancel != nil { + s.cancel() + } + if s.saveTicker != nil { + s.saveTicker.Stop() + } + err := s.saveCache() + if err != nil { + s.logger.Error(E.Cause(err, "save cache")) + } + return common.Close( + common.PtrOrNil(s.httpServer), + common.PtrOrNil(s.listener), + s.tlsConfig, + ) +} diff --git a/service/ssmapi/traffic.go b/service/ssmapi/traffic.go new file mode 100644 index 00000000..4a669adb --- /dev/null +++ b/service/ssmapi/traffic.go @@ -0,0 +1,223 @@ +package ssmapi + +import ( + "net" + "sync" + "sync/atomic" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/bufio" + N "github.com/sagernet/sing/common/network" +) + +var _ adapter.SSMTracker = (*TrafficManager)(nil) + +type TrafficManager struct { + globalUplink atomic.Int64 + globalDownlink atomic.Int64 + globalUplinkPackets atomic.Int64 + globalDownlinkPackets atomic.Int64 + globalTCPSessions atomic.Int64 + globalUDPSessions atomic.Int64 + userAccess sync.Mutex + userUplink map[string]*atomic.Int64 + userDownlink map[string]*atomic.Int64 + userUplinkPackets map[string]*atomic.Int64 + userDownlinkPackets map[string]*atomic.Int64 + userTCPSessions map[string]*atomic.Int64 + userUDPSessions map[string]*atomic.Int64 +} + +func NewTrafficManager() *TrafficManager { + manager := &TrafficManager{ + userUplink: make(map[string]*atomic.Int64), + userDownlink: make(map[string]*atomic.Int64), + userUplinkPackets: make(map[string]*atomic.Int64), + userDownlinkPackets: make(map[string]*atomic.Int64), + userTCPSessions: make(map[string]*atomic.Int64), + userUDPSessions: make(map[string]*atomic.Int64), + } + return manager +} + +func (s *TrafficManager) UpdateUsers(users []string) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + newUserUplink := make(map[string]*atomic.Int64) + newUserDownlink := make(map[string]*atomic.Int64) + newUserUplinkPackets := make(map[string]*atomic.Int64) + newUserDownlinkPackets := make(map[string]*atomic.Int64) + newUserTCPSessions := make(map[string]*atomic.Int64) + newUserUDPSessions := make(map[string]*atomic.Int64) + for _, user := range users { + if counter, loaded := s.userUplink[user]; loaded { + newUserUplink[user] = counter + } + if counter, loaded := s.userDownlink[user]; loaded { + newUserDownlink[user] = counter + } + if counter, loaded := s.userUplinkPackets[user]; loaded { + newUserUplinkPackets[user] = counter + } + if counter, loaded := s.userDownlinkPackets[user]; loaded { + newUserDownlinkPackets[user] = counter + } + if counter, loaded := s.userTCPSessions[user]; loaded { + newUserTCPSessions[user] = counter + } + if counter, loaded := s.userUDPSessions[user]; loaded { + newUserUDPSessions[user] = counter + } + } + s.userUplink = newUserUplink + s.userDownlink = newUserDownlink + s.userUplinkPackets = newUserUplinkPackets + s.userDownlinkPackets = newUserDownlinkPackets + s.userTCPSessions = newUserTCPSessions + s.userUDPSessions = newUserUDPSessions +} + +func (s *TrafficManager) userCounter(user string) (*atomic.Int64, *atomic.Int64, *atomic.Int64, *atomic.Int64, *atomic.Int64, *atomic.Int64) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + upCounter, loaded := s.userUplink[user] + if !loaded { + upCounter = new(atomic.Int64) + s.userUplink[user] = upCounter + } + downCounter, loaded := s.userDownlink[user] + if !loaded { + downCounter = new(atomic.Int64) + s.userDownlink[user] = downCounter + } + upPacketsCounter, loaded := s.userUplinkPackets[user] + if !loaded { + upPacketsCounter = new(atomic.Int64) + s.userUplinkPackets[user] = upPacketsCounter + } + downPacketsCounter, loaded := s.userDownlinkPackets[user] + if !loaded { + downPacketsCounter = new(atomic.Int64) + s.userDownlinkPackets[user] = downPacketsCounter + } + tcpSessionsCounter, loaded := s.userTCPSessions[user] + if !loaded { + tcpSessionsCounter = new(atomic.Int64) + s.userTCPSessions[user] = tcpSessionsCounter + } + udpSessionsCounter, loaded := s.userUDPSessions[user] + if !loaded { + udpSessionsCounter = new(atomic.Int64) + s.userUDPSessions[user] = udpSessionsCounter + } + return upCounter, downCounter, upPacketsCounter, downPacketsCounter, tcpSessionsCounter, udpSessionsCounter +} + +func (s *TrafficManager) TrackConnection(conn net.Conn, metadata adapter.InboundContext) net.Conn { + s.globalTCPSessions.Add(1) + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + readCounter = append(readCounter, &s.globalUplink) + writeCounter = append(writeCounter, &s.globalDownlink) + upCounter, downCounter, _, _, tcpSessionCounter, _ := s.userCounter(metadata.User) + readCounter = append(readCounter, upCounter) + writeCounter = append(writeCounter, downCounter) + tcpSessionCounter.Add(1) + return bufio.NewInt64CounterConn(conn, readCounter, writeCounter) +} + +func (s *TrafficManager) TrackPacketConnection(conn N.PacketConn, metadata adapter.InboundContext) N.PacketConn { + s.globalUDPSessions.Add(1) + var readCounter []*atomic.Int64 + var readPacketCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + var writePacketCounter []*atomic.Int64 + readCounter = append(readCounter, &s.globalUplink) + writeCounter = append(writeCounter, &s.globalDownlink) + readPacketCounter = append(readPacketCounter, &s.globalUplinkPackets) + writePacketCounter = append(writePacketCounter, &s.globalDownlinkPackets) + upCounter, downCounter, upPacketsCounter, downPacketsCounter, _, udpSessionCounter := s.userCounter(metadata.User) + readCounter = append(readCounter, upCounter) + writeCounter = append(writeCounter, downCounter) + readPacketCounter = append(readPacketCounter, upPacketsCounter) + writePacketCounter = append(writePacketCounter, downPacketsCounter) + udpSessionCounter.Add(1) + return bufio.NewInt64CounterPacketConn(conn, readCounter, readPacketCounter, writeCounter, writePacketCounter) +} + +func (s *TrafficManager) ReadUser(user *UserObject) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + s.readUser(user, false) +} + +func (s *TrafficManager) readUser(user *UserObject, swap bool) { + if counter, loaded := s.userUplink[user.UserName]; loaded { + if swap { + user.UplinkBytes = counter.Swap(0) + } else { + user.UplinkBytes = counter.Load() + } + } + if counter, loaded := s.userDownlink[user.UserName]; loaded { + if swap { + user.DownlinkBytes = counter.Swap(0) + } else { + user.DownlinkBytes = counter.Load() + } + } + if counter, loaded := s.userUplinkPackets[user.UserName]; loaded { + if swap { + user.UplinkPackets = counter.Swap(0) + } else { + user.UplinkPackets = counter.Load() + } + } + if counter, loaded := s.userDownlinkPackets[user.UserName]; loaded { + if swap { + user.DownlinkPackets = counter.Swap(0) + } else { + user.DownlinkPackets = counter.Load() + } + } + if counter, loaded := s.userTCPSessions[user.UserName]; loaded { + if swap { + user.TCPSessions = counter.Swap(0) + } else { + user.TCPSessions = counter.Load() + } + } + if counter, loaded := s.userUDPSessions[user.UserName]; loaded { + if swap { + user.UDPSessions = counter.Swap(0) + } else { + user.UDPSessions = counter.Load() + } + } +} + +func (s *TrafficManager) ReadUsers(users []*UserObject, swap bool) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + for _, user := range users { + s.readUser(user, swap) + } +} + +func (s *TrafficManager) ReadGlobal(swap bool) (uplinkBytes int64, downlinkBytes int64, uplinkPackets int64, downlinkPackets int64, tcpSessions int64, udpSessions int64) { + if swap { + return s.globalUplink.Swap(0), + s.globalDownlink.Swap(0), + s.globalUplinkPackets.Swap(0), + s.globalDownlinkPackets.Swap(0), + s.globalTCPSessions.Swap(0), + s.globalUDPSessions.Swap(0) + } else { + return s.globalUplink.Load(), + s.globalDownlink.Load(), + s.globalUplinkPackets.Load(), + s.globalDownlinkPackets.Load(), + s.globalTCPSessions.Load(), + s.globalUDPSessions.Load() + } +} diff --git a/service/ssmapi/user.go b/service/ssmapi/user.go new file mode 100644 index 00000000..26bc621a --- /dev/null +++ b/service/ssmapi/user.go @@ -0,0 +1,87 @@ +package ssmapi + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" +) + +type UserManager struct { + access sync.Mutex + usersMap map[string]string + server adapter.ManagedSSMServer + trafficManager *TrafficManager +} + +func NewUserManager(inbound adapter.ManagedSSMServer, trafficManager *TrafficManager) *UserManager { + return &UserManager{ + usersMap: make(map[string]string), + server: inbound, + trafficManager: trafficManager, + } +} + +func (m *UserManager) postUpdate(updated bool) error { + users := make([]string, 0, len(m.usersMap)) + uPSKs := make([]string, 0, len(m.usersMap)) + for username, password := range m.usersMap { + users = append(users, username) + uPSKs = append(uPSKs, password) + } + err := m.server.UpdateUsers(users, uPSKs) + if err != nil { + return err + } + if updated { + m.trafficManager.UpdateUsers(users) + } + return nil +} + +func (m *UserManager) List() []*UserObject { + m.access.Lock() + defer m.access.Unlock() + + users := make([]*UserObject, 0, len(m.usersMap)) + for username, password := range m.usersMap { + users = append(users, &UserObject{ + UserName: username, + Password: password, + }) + } + return users +} + +func (m *UserManager) Add(username string, password string) error { + m.access.Lock() + defer m.access.Unlock() + if _, found := m.usersMap[username]; found { + return E.New("user ", username, " already exists") + } + m.usersMap[username] = password + return m.postUpdate(true) +} + +func (m *UserManager) Get(username string) (string, bool) { + m.access.Lock() + defer m.access.Unlock() + if password, found := m.usersMap[username]; found { + return password, true + } + return "", false +} + +func (m *UserManager) Update(username string, password string) error { + m.access.Lock() + defer m.access.Unlock() + m.usersMap[username] = password + return m.postUpdate(true) +} + +func (m *UserManager) Delete(username string) error { + m.access.Lock() + defer m.access.Unlock() + delete(m.usersMap, username) + return m.postUpdate(true) +} diff --git a/test/box_test.go b/test/box_test.go new file mode 100644 index 00000000..d7d9b9b0 --- /dev/null +++ b/test/box_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/debug" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +var globalCtx context.Context + +func init() { + globalCtx = include.Context(context.Background()) +} + +func startInstance(t *testing.T, options option.Options) *box.Box { + if debug.Enabled { + options.Log = &option.LogOptions{ + Level: "trace", + } + } else { + options.Log = &option.LogOptions{ + Level: "warning", + } + } + ctx, cancel := context.WithCancel(globalCtx) + var instance *box.Box + var err error + for retry := 0; retry < 3; retry++ { + instance, err = box.New(box.Options{ + Context: ctx, + Options: options, + }) + require.NoError(t, err) + err = instance.Start() + if err != nil { + time.Sleep(time.Second) + continue + } + break + } + require.NoError(t, err) + t.Cleanup(func() { + instance.Close() + cancel() + }) + return instance +} + +func testSuit(t *testing.T, clientPort uint16, testPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + dialUDP := func() (net.PacketConn, error) { + return dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testLargeDataWithConn(t, testPort, dialTCP)) + require.NoError(t, testLargeDataWithPacketConn(t, testPort, dialUDP)) + + // require.NoError(t, testPacketConnTimeout(t, dialUDP)) +} + +func testQUIC(t *testing.T, clientPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + client := &http.Client{ + Transport: &http3.Transport{ + Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) { + destination := M.ParseSocksaddr(addr) + udpConn, err := dialer.DialContext(ctx, N.NetworkUDP, destination) + if err != nil { + return nil, err + } + return quic.DialEarly(ctx, udpConn.(net.PacketConn), destination, tlsCfg, cfg) + }, + }, + } + response, err := client.Get("https://cloudflare.com/cdn-cgi/trace") + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + content, err := io.ReadAll(response.Body) + require.NoError(t, err) + println(string(content)) +} + +func testSuitLargeUDP(t *testing.T, clientPort uint16, testPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + dialUDP := func() (net.PacketConn, error) { + return dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testLargeDataWithConn(t, testPort, dialTCP)) + require.NoError(t, testLargeDataWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testLargeDataWithPacketConnSize(t, testPort, 4096, dialUDP)) +} + +func testTCP(t *testing.T, clientPort uint16, testPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + require.NoError(t, testLargeDataWithConn(t, testPort, dialTCP)) +} + +func testSuitSimple(t *testing.T, clientPort uint16, testPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + dialUDP := func() (net.PacketConn, error) { + return dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) +} + +func testSuitSimple1(t *testing.T, clientPort uint16, testPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + dialUDP := func() (net.PacketConn, error) { + return dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + if !C.IsDarwin { + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + if !C.IsDarwin { + require.NoError(t, testLargeDataWithPacketConn(t, testPort, dialUDP)) + } +} + +func testSuitWg(t *testing.T, clientPort uint16, testPort uint16) { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("10.0.0.1", testPort)) + } + dialUDP := func() (net.PacketConn, error) { + conn, err := dialer.DialContext(context.Background(), "udp", M.ParseSocksaddrHostPort("10.0.0.1", testPort)) + if err != nil { + return nil, err + } + return bufio.NewUnbindPacketConn(conn), nil + } + require.NoError(t, testPingPongWithConn(t, testPort, dialTCP)) + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testLargeDataWithConn(t, testPort, dialTCP)) + require.NoError(t, testLargeDataWithPacketConn(t, testPort, dialUDP)) +} diff --git a/test/brutal_test.go b/test/brutal_test.go new file mode 100644 index 00000000..d0841467 --- /dev/null +++ b/test/brutal_test.go @@ -0,0 +1,392 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +func TestBrutalShadowsocks(t *testing.T) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Method: method, + Password: password, + Multiplex: &option.InboundMultiplexOptions{ + Enabled: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password, + Multiplex: &option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "smux", + Padding: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestBrutalTrojan(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{{Password: password}}, + Multiplex: &option.InboundMultiplexOptions{ + Enabled: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "ss-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: password, + Multiplex: &option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "yamux", + Padding: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestBrutalVMess(t *testing.T) { + user, _ := uuid.NewV4() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{{UUID: user.String()}}, + Multiplex: &option.InboundMultiplexOptions{ + Enabled: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "ss-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + Multiplex: &option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "h2mux", + Padding: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestBrutalVLESS(t *testing.T) { + user, _ := uuid.NewV4() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVLESS, + Options: &option.VLESSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VLESSUser{{UUID: user.String()}}, + Multiplex: &option.InboundMultiplexOptions{ + Enabled: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + Reality: &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "google.com", + ServerPort: 443, + }, + }, + ShortID: []string{"0123456789abcdef"}, + PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVLESS, + Tag: "ss-out", + Options: &option.VLESSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + Reality: &option.OutboundRealityOptions{ + Enabled: true, + ShortID: "0123456789abcdef", + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + }, + UTLS: &option.OutboundUTLSOptions{ + Enabled: true, + }, + }, + }, + Multiplex: &option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "h2mux", + Padding: true, + Brutal: &option.BrutalOptions{ + Enabled: true, + UpMbps: 100, + DownMbps: 100, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/clash_darwin_test.go b/test/clash_darwin_test.go new file mode 100644 index 00000000..013d8b3f --- /dev/null +++ b/test/clash_darwin_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "fmt" + "net" + "net/netip" + "syscall" + + "golang.org/x/net/route" +) + +func defaultRouteIP() (netip.Addr, error) { + idx, err := defaultRouteInterfaceIndex() + if err != nil { + return netip.Addr{}, err + } + iface, err := net.InterfaceByIndex(idx) + if err != nil { + return netip.Addr{}, err + } + addrs, err := iface.Addrs() + if err != nil { + return netip.Addr{}, err + } + for _, addr := range addrs { + ip := addr.(*net.IPNet).IP + if ip.To4() != nil { + return netip.AddrFrom4([4]byte(ip)), nil + } + } + + return netip.Addr{}, errors.New("no ipv4 addr") +} + +func defaultRouteInterfaceIndex() (int, error) { + rib, err := route.FetchRIB(syscall.AF_UNSPEC, syscall.NET_RT_DUMP2, 0) + if err != nil { + return 0, fmt.Errorf("route.FetchRIB: %w", err) + } + msgs, err := route.ParseRIB(syscall.NET_RT_IFLIST2, rib) + if err != nil { + return 0, fmt.Errorf("route.ParseRIB: %w", err) + } + for _, message := range msgs { + routeMessage := message.(*route.RouteMessage) + if routeMessage.Flags&(syscall.RTF_UP|syscall.RTF_GATEWAY|syscall.RTF_STATIC) == 0 { + continue + } + + addresses := routeMessage.Addrs + + destination, ok := addresses[0].(*route.Inet4Addr) + if !ok { + continue + } + + if destination.IP != [4]byte{0, 0, 0, 0} { + continue + } + + switch addresses[1].(type) { + case *route.Inet4Addr: + return routeMessage.Index, nil + default: + continue + } + } + + return 0, fmt.Errorf("ambiguous gateway interfaces found") +} diff --git a/test/clash_other_test.go b/test/clash_other_test.go new file mode 100644 index 00000000..fc4a68af --- /dev/null +++ b/test/clash_other_test.go @@ -0,0 +1,12 @@ +//go:build !darwin + +package main + +import ( + "errors" + "net/netip" +) + +func defaultRouteIP() (netip.Addr, error) { + return netip.Addr{}, errors.New("not supported") +} diff --git a/test/clash_test.go b/test/clash_test.go new file mode 100644 index 00000000..bba7f3be --- /dev/null +++ b/test/clash_test.go @@ -0,0 +1,524 @@ +package main + +import ( + "context" + "crypto/md5" + "crypto/rand" + "errors" + "io" + "net" + _ "net/http/pprof" + "net/netip" + "sync" + "testing" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common/control" + F "github.com/sagernet/sing/common/format" + + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// kanged from clash + +const ( + ImageShadowsocksRustServer = "ghcr.io/shadowsocks/ssserver-rust:latest" + ImageShadowsocksRustClient = "ghcr.io/shadowsocks/sslocal-rust:latest" + ImageV2RayCore = "v2fly/v2fly-core:latest" + ImageTrojan = "trojangfw/trojan:latest" + ImageNaive = "pocat/naiveproxy:client" + ImageBoringTun = "ghcr.io/ntkme/boringtun:edge" + ImageHysteria = "tobyxdd/hysteria:v1.3.5" + ImageHysteria2 = "tobyxdd/hysteria:v2" + ImageNginx = "nginx:stable" + ImageShadowTLS = "ghcr.io/ihciah/shadow-tls:latest" + ImageXRayCore = "teddysun/xray:latest" + ImageShadowsocksLegacy = "mritd/shadowsocks:latest" + ImageTUICServer = "kilvn/tuic-server:latest" + ImageTUICClient = "kilvn/tuic-client:latest" +) + +var allImages = []string{ + ImageShadowsocksRustServer, + ImageShadowsocksRustClient, + ImageV2RayCore, + ImageTrojan, + ImageNaive, + ImageBoringTun, + ImageHysteria, + ImageHysteria2, + ImageNginx, + ImageShadowTLS, + ImageXRayCore, + ImageShadowsocksLegacy, + ImageTUICServer, + ImageTUICClient, +} + +var localIP = netip.MustParseAddr("127.0.0.1") + +func init() { + dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + panic(err) + } + defer dockerClient.Close() + + list, err := dockerClient.ImageList(context.Background(), image.ListOptions{All: true}) + if err != nil { + log.Warn(err) + return + } + + imageExist := func(image string) bool { + for _, item := range list { + for _, tag := range item.RepoTags { + if image == tag { + return true + } + } + } + return false + } + + for _, i := range allImages { + if imageExist(i) { + continue + } + + log.Info("pulling image: ", i) + imageStream, err := dockerClient.ImagePull(context.Background(), i, image.PullOptions{}) + if err != nil { + panic(err) + } + + io.Copy(io.Discard, imageStream) + } +} + +func newPingPongPair() (chan []byte, chan []byte, func(t *testing.T) error) { + pingCh := make(chan []byte) + pongCh := make(chan []byte) + test := func(t *testing.T) error { + defer close(pingCh) + defer close(pongCh) + pingOpen := false + pongOpen := false + var recv []byte + + for { + if pingOpen && pongOpen { + break + } + + select { + case recv, pingOpen = <-pingCh: + assert.True(t, pingOpen) + assert.Equal(t, []byte("ping"), recv) + case recv, pongOpen = <-pongCh: + assert.True(t, pongOpen) + assert.Equal(t, []byte("pong"), recv) + case <-time.After(10 * time.Second): + return errors.New("timeout") + } + } + return nil + } + + return pingCh, pongCh, test +} + +func newLargeDataPair() (chan hashPair, chan hashPair, func(t *testing.T) error) { + pingCh := make(chan hashPair) + pongCh := make(chan hashPair) + test := func(t *testing.T) error { + defer close(pingCh) + defer close(pongCh) + pingOpen := false + pongOpen := false + var serverPair hashPair + var clientPair hashPair + + for { + if pingOpen && pongOpen { + break + } + + select { + case serverPair, pingOpen = <-pingCh: + assert.True(t, pingOpen) + case clientPair, pongOpen = <-pongCh: + assert.True(t, pongOpen) + case <-time.After(10 * time.Second): + return errors.New("timeout") + } + } + + assert.Equal(t, serverPair.recvHash, clientPair.sendHash) + assert.Equal(t, serverPair.sendHash, clientPair.recvHash) + + return nil + } + + return pingCh, pongCh, test +} + +func testPingPongWithConn(t *testing.T, port uint16, cc func() (net.Conn, error)) error { + l, err := listen("tcp", ":"+F.ToString(port)) + if err != nil { + return err + } + defer l.Close() + + c, err := cc() + if err != nil { + return err + } + defer c.Close() + + pingCh, pongCh, test := newPingPongPair() + go func() { + c, err := l.Accept() + if err != nil { + return + } + + buf := make([]byte, 4) + if _, err := io.ReadFull(c, buf); err != nil { + return + } + + pingCh <- buf + if _, err := c.Write([]byte("pong")); err != nil { + return + } + }() + + go func() { + if _, err := c.Write([]byte("ping")); err != nil { + return + } + + buf := make([]byte, 4) + if _, err := io.ReadFull(c, buf); err != nil { + return + } + + pongCh <- buf + }() + + return test(t) +} + +func testPingPongWithPacketConn(t *testing.T, port uint16, pcc func() (net.PacketConn, error)) error { + l, err := listenPacket("udp", ":"+F.ToString(port)) + if err != nil { + return err + } + defer l.Close() + + rAddr := &net.UDPAddr{IP: localIP.AsSlice(), Port: int(port)} + + pingCh, pongCh, test := newPingPongPair() + go func() { + buf := make([]byte, 1024) + n, rAddr, err := l.ReadFrom(buf) + if err != nil { + return + } + + pingCh <- buf[:n] + if _, err := l.WriteTo([]byte("pong"), rAddr); err != nil { + return + } + }() + + pc, err := pcc() + if err != nil { + return err + } + defer pc.Close() + + go func() { + if _, err := pc.WriteTo([]byte("ping"), rAddr); err != nil { + return + } + + buf := make([]byte, 1024) + n, _, err := pc.ReadFrom(buf) + if err != nil { + return + } + + pongCh <- buf[:n] + }() + + return test(t) +} + +type hashPair struct { + sendHash map[int][]byte + recvHash map[int][]byte +} + +func testLargeDataWithConn(t *testing.T, port uint16, cc func() (net.Conn, error)) error { + l, err := listen("tcp", ":"+F.ToString(port)) + require.NoError(t, err) + defer l.Close() + + times := 100 + chunkSize := int64(64 * 1024) + + pingCh, pongCh, test := newLargeDataPair() + writeRandData := func(conn net.Conn) (map[int][]byte, error) { + buf := make([]byte, chunkSize) + hashMap := map[int][]byte{} + for i := 0; i < times; i++ { + if _, err := rand.Read(buf[1:]); err != nil { + return nil, err + } + buf[0] = byte(i) + + hash := md5.Sum(buf) + hashMap[i] = hash[:] + + if _, err := conn.Write(buf); err != nil { + return nil, err + } + } + + return hashMap, nil + } + + c, err := cc() + if err != nil { + return err + } + defer c.Close() + + go func() { + c, err := l.Accept() + if err != nil { + return + } + defer c.Close() + + hashMap := map[int][]byte{} + buf := make([]byte, chunkSize) + + for i := 0; i < times; i++ { + _, err := io.ReadFull(c, buf) + if err != nil { + t.Log(err.Error()) + return + } + + hash := md5.Sum(buf) + hashMap[int(buf[0])] = hash[:] + } + + sendHash, err := writeRandData(c) + if err != nil { + t.Log(err.Error()) + return + } + + pingCh <- hashPair{ + sendHash: sendHash, + recvHash: hashMap, + } + }() + + go func() { + sendHash, err := writeRandData(c) + if err != nil { + t.Log(err.Error()) + return + } + + hashMap := map[int][]byte{} + buf := make([]byte, chunkSize) + + for i := 0; i < times; i++ { + _, err := io.ReadFull(c, buf) + if err != nil { + t.Log(err.Error()) + return + } + + hash := md5.Sum(buf) + hashMap[int(buf[0])] = hash[:] + } + + pongCh <- hashPair{ + sendHash: sendHash, + recvHash: hashMap, + } + }() + + return test(t) +} + +func testLargeDataWithPacketConn(t *testing.T, port uint16, pcc func() (net.PacketConn, error)) error { + return testLargeDataWithPacketConnSize(t, port, 1500, pcc) +} + +func testLargeDataWithPacketConnSize(t *testing.T, port uint16, chunkSize int, pcc func() (net.PacketConn, error)) error { + l, err := listenPacket("udp", ":"+F.ToString(port)) + if err != nil { + return err + } + defer l.Close() + + rAddr := &net.UDPAddr{IP: localIP.AsSlice(), Port: int(port)} + + times := 50 + + pingCh, pongCh, test := newLargeDataPair() + writeRandData := func(pc net.PacketConn, addr net.Addr) (map[int][]byte, error) { + hashMap := map[int][]byte{} + mux := sync.Mutex{} + for i := 0; i < times; i++ { + buf := make([]byte, chunkSize) + if _, err := rand.Read(buf[1:]); err != nil { + t.Log(err.Error()) + continue + } + buf[0] = byte(i) + + hash := md5.Sum(buf) + mux.Lock() + hashMap[i] = hash[:] + mux.Unlock() + + if _, err := pc.WriteTo(buf, addr); err != nil { + t.Log(err.Error()) + } + + time.Sleep(10 * time.Millisecond) + } + + return hashMap, nil + } + + go func() { + var rAddr net.Addr + hashMap := map[int][]byte{} + buf := make([]byte, 64*1024) + + for i := 0; i < times; i++ { + _, rAddr, err = l.ReadFrom(buf) + if err != nil { + t.Log(err.Error()) + return + } + hash := md5.Sum(buf[:chunkSize]) + hashMap[int(buf[0])] = hash[:] + } + sendHash, err := writeRandData(l, rAddr) + if err != nil { + t.Log(err.Error()) + return + } + + pingCh <- hashPair{ + sendHash: sendHash, + recvHash: hashMap, + } + }() + + pc, err := pcc() + if err != nil { + return err + } + defer pc.Close() + + go func() { + sendHash, err := writeRandData(pc, rAddr) + if err != nil { + t.Log(err.Error()) + return + } + + hashMap := map[int][]byte{} + buf := make([]byte, 64*1024) + + for i := 0; i < times; i++ { + _, _, err := pc.ReadFrom(buf) + if err != nil { + t.Log(err.Error()) + return + } + + hash := md5.Sum(buf[:chunkSize]) + hashMap[int(buf[0])] = hash[:] + } + + pongCh <- hashPair{ + sendHash: sendHash, + recvHash: hashMap, + } + }() + + return test(t) +} + +func testPacketConnTimeout(t *testing.T, pcc func() (net.PacketConn, error)) error { + pc, err := pcc() + if err != nil { + return err + } + + err = pc.SetReadDeadline(time.Now().Add(time.Millisecond * 300)) + require.NoError(t, err) + + errCh := make(chan error, 1) + go func() { + buf := make([]byte, 1024) + _, _, err := pc.ReadFrom(buf) + errCh <- err + }() + + select { + case <-errCh: + return nil + case <-time.After(time.Second * 10): + return errors.New("timeout") + } +} + +func listen(network, address string) (net.Listener, error) { + var lc net.ListenConfig + lc.Control = control.ReuseAddr() + var lastErr error + for i := 0; i < 5; i++ { + l, err := lc.Listen(context.Background(), network, address) + if err == nil { + return l, nil + } + + lastErr = err + time.Sleep(5 * time.Millisecond) + } + return nil, lastErr +} + +func listenPacket(network, address string) (net.PacketConn, error) { + var lc net.ListenConfig + lc.Control = control.ReuseAddr() + var lastErr error + for i := 0; i < 5; i++ { + l, err := lc.ListenPacket(context.Background(), network, address) + if err == nil { + return l, nil + } + + lastErr = err + time.Sleep(5 * time.Millisecond) + } + return nil, lastErr +} diff --git a/test/config/hysteria-client.json b/test/config/hysteria-client.json new file mode 100644 index 00000000..3328c510 --- /dev/null +++ b/test/config/hysteria-client.json @@ -0,0 +1,12 @@ +{ + "server": "127.0.0.1:10000", + "auth_str": "password", + "obfs": "fuck me till the daylight", + "up_mbps": 100, + "down_mbps": 100, + "socks5": { + "listen": "127.0.0.1:10001" + }, + "server_name": "example.org", + "ca": "/etc/hysteria/ca.pem" +} \ No newline at end of file diff --git a/test/config/hysteria-server.json b/test/config/hysteria-server.json new file mode 100644 index 00000000..e33624a2 --- /dev/null +++ b/test/config/hysteria-server.json @@ -0,0 +1,9 @@ +{ + "listen": ":10000", + "cert": "/etc/hysteria/cert.pem", + "key": "/etc/hysteria/key.pem", + "auth_str": "password", + "obfs": "fuck me till the daylight", + "up_mbps": 100, + "down_mbps": 100 +} \ No newline at end of file diff --git a/test/config/hysteria2-client.yml b/test/config/hysteria2-client.yml new file mode 100644 index 00000000..f9cc2ede --- /dev/null +++ b/test/config/hysteria2-client.yml @@ -0,0 +1,11 @@ +server: 127.0.0.1:10000 +auth: password +socks5: + listen: 127.0.0.1:10001 +tls: + sni: example.org + ca: /etc/hysteria/ca.pem +obfs: + type: salamander + salamander: + password: cry_me_a_r1ver \ No newline at end of file diff --git a/test/config/hysteria2-server.yml b/test/config/hysteria2-server.yml new file mode 100644 index 00000000..1d1ef1c3 --- /dev/null +++ b/test/config/hysteria2-server.yml @@ -0,0 +1,12 @@ +listen: 127.0.0.1:10000 +auth: + type: password + password: password +tls: + sni: example.org + cert: /etc/hysteria/cert.pem + key: /etc/hysteria/key.pem +obfs: + type: salamander + salamander: + password: cry_me_a_r1ver \ No newline at end of file diff --git a/test/config/naive-nginx.conf b/test/config/naive-nginx.conf new file mode 100644 index 00000000..bcd06c04 --- /dev/null +++ b/test/config/naive-nginx.conf @@ -0,0 +1,18 @@ +stream { + server { + listen 10000 ssl; + listen [::]:10000 ssl; + + ssl_certificate /etc/nginx/cert.pem; + ssl_certificate_key /etc/nginx/key.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + + # modern configuration + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; + + proxy_pass 127.0.0.1:10003; + } +} \ No newline at end of file diff --git a/test/config/naive-quic.json b/test/config/naive-quic.json new file mode 100644 index 00000000..ec891cd8 --- /dev/null +++ b/test/config/naive-quic.json @@ -0,0 +1,6 @@ +{ + "listen": "socks://127.0.0.1:10001", + "proxy": "quic://sekai:password@example.org:10000", + "host-resolver-rules": "MAP example.org 127.0.0.1", + "log": "" +} \ No newline at end of file diff --git a/test/config/naive.json b/test/config/naive.json new file mode 100644 index 00000000..c7b21101 --- /dev/null +++ b/test/config/naive.json @@ -0,0 +1,6 @@ +{ + "listen": "socks://127.0.0.1:10001", + "proxy": "https://sekai:password@example.org:10000", + "host-resolver-rules": "MAP example.org 127.0.0.1", + "log": "" +} \ No newline at end of file diff --git a/test/config/nginx.conf b/test/config/nginx.conf new file mode 100644 index 00000000..124218fd --- /dev/null +++ b/test/config/nginx.conf @@ -0,0 +1,29 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + #gzip on; +} + +include /etc/nginx/conf.d/naive.conf; \ No newline at end of file diff --git a/test/config/shadowsocksr.json b/test/config/shadowsocksr.json new file mode 100644 index 00000000..efae403c --- /dev/null +++ b/test/config/shadowsocksr.json @@ -0,0 +1,19 @@ +{ + "server": "0.0.0.0", + "server_ipv6": "::", + "server_port": 10000, + "local_address": "127.0.0.1", + "local_port": 1080, + "password": "password0", + "timeout": 120, + "method": "aes-256-cfb", + "protocol": "origin", + "protocol_param": "", + "obfs": "plain", + "obfs_param": "", + "redirect": "", + "dns_ipv6": false, + "fast_open": true, + "workers": 1, + "forbidden_ip": "" +} \ No newline at end of file diff --git a/test/config/trojan.json b/test/config/trojan.json new file mode 100644 index 00000000..d645fc7a --- /dev/null +++ b/test/config/trojan.json @@ -0,0 +1,40 @@ +{ + "run_type": "server", + "local_addr": "0.0.0.0", + "local_port": 10000, + "password": [ + "password" + ], + "log_level": 1, + "ssl": { + "cert": "/path/to/certificate.crt", + "key": "/path/to/private.key", + "key_password": "", + "cipher": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384", + "cipher_tls13": "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384", + "prefer_server_cipher": true, + "alpn": [ + "http/1.1" + ], + "alpn_port_override": { + "h2": 81 + }, + "reuse_session": true, + "session_ticket": false, + "session_timeout": 600, + "plain_http_response": "", + "curves": "", + "dhparam": "" + }, + "tcp": { + "prefer_ipv4": false, + "no_delay": true, + "keep_alive": true, + "reuse_port": false, + "fast_open": false, + "fast_open_qlen": 20 + }, + "mysql": { + "enabled": false + } +} \ No newline at end of file diff --git a/test/config/tuic-client.json b/test/config/tuic-client.json new file mode 100644 index 00000000..0db1755c --- /dev/null +++ b/test/config/tuic-client.json @@ -0,0 +1,16 @@ +{ + "relay": { + "server": "example.org:10000", + "ip": "127.0.0.1", + "uuid": "FE35D05B-8803-45C4-BAE6-723AD2CD5D3D", + "password": "tuic", + "certificates": [ + "/etc/tuic/ca.pem" + ] + }, + "local": { + "server": "127.0.0.1:10001", + "max_packet_size": 65535 + }, + "log_level": "debug" +} \ No newline at end of file diff --git a/test/config/tuic-server.json b/test/config/tuic-server.json new file mode 100644 index 00000000..4907796b --- /dev/null +++ b/test/config/tuic-server.json @@ -0,0 +1,10 @@ +{ + "server": "[::]:10000", + "users": { + "FE35D05B-8803-45C4-BAE6-723AD2CD5D3D": "tuic" + }, + "certificate": "/etc/tuic/cert.pem", + "private_key": "/etc/tuic/key.pem", + "max_external_packet_size": 65535, + "log_level": "debug" +} \ No newline at end of file diff --git a/test/config/vless-server.json b/test/config/vless-server.json new file mode 100644 index 00000000..b5a7bfd0 --- /dev/null +++ b/test/config/vless-server.json @@ -0,0 +1,25 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": 1234, + "protocol": "vless", + "settings": { + "decryption": "none", + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811" + } + ] + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} \ No newline at end of file diff --git a/test/config/vless-tls-client.json b/test/config/vless-tls-client.json new file mode 100644 index 00000000..e0313547 --- /dev/null +++ b/test/config/vless-tls-client.json @@ -0,0 +1,51 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": "1080", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "host.docker.internal", + "port": 1234, + "users": [ + { + "id": "", + "encryption": "none", + "flow": "" + } + ] + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "tls", + "tlsSettings": { + "serverName": "example.org", + "certificates": [ + { + "certificateFile": "/path/to/certificate.crt", + "keyFile": "/path/to/private.key" + } + ], + "fingerprint": "chrome" + } + } + } + ] +} \ No newline at end of file diff --git a/test/config/vless-tls-server.json b/test/config/vless-tls-server.json new file mode 100644 index 00000000..1c0f3524 --- /dev/null +++ b/test/config/vless-tls-server.json @@ -0,0 +1,39 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": 1234, + "protocol": "vless", + "settings": { + "decryption": "none", + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811", + "flow": "" + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "tls", + "tlsSettings": { + "serverName": "example.org", + "certificates": [ + { + "certificateFile": "/path/to/certificate.crt", + "keyFile": "/path/to/private.key" + } + ] + } + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-client.json b/test/config/vmess-client.json new file mode 100644 index 00000000..72381a99 --- /dev/null +++ b/test/config/vmess-client.json @@ -0,0 +1,38 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": "1080", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "protocol": "vmess", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 1234, + "users": [ + { + "id": "", + "alterId": 0, + "security": "none", + "experiments": "" + } + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-grpc-client.json b/test/config/vmess-grpc-client.json new file mode 100644 index 00000000..a5cd49a5 --- /dev/null +++ b/test/config/vmess-grpc-client.json @@ -0,0 +1,51 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": "1080", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "protocol": "vmess", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 1234, + "users": [ + { + "id": "" + } + ] + } + ] + }, + "streamSettings": { + "network": "gun", + "security": "tls", + "tlsSettings": { + "serverName": "example.org", + "certificates": [ + { + "certificateFile": "/path/to/certificate.crt", + "keyFile": "/path/to/private.key" + } + ] + }, + "grpcSettings": { + "serviceName": "TunService" + } + } + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-grpc-server.json b/test/config/vmess-grpc-server.json new file mode 100644 index 00000000..3867d447 --- /dev/null +++ b/test/config/vmess-grpc-server.json @@ -0,0 +1,40 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": 1234, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811" + } + ] + }, + "streamSettings": { + "network": "gun", + "security": "tls", + "tlsSettings": { + "serverName": "example.org", + "certificates": [ + { + "certificateFile": "/path/to/certificate.crt", + "keyFile": "/path/to/private.key" + } + ] + }, + "grpcSettings": { + "serviceName": "TunService" + } + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-mux-client.json b/test/config/vmess-mux-client.json new file mode 100644 index 00000000..9cb6654b --- /dev/null +++ b/test/config/vmess-mux-client.json @@ -0,0 +1,41 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": "1080", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "protocol": "vmess", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 1234, + "users": [ + { + "id": "", + "alterId": 0, + "security": "none", + "experiments": "" + } + ] + } + ] + }, + "mux": { + "enabled": true + } + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-server.json b/test/config/vmess-server.json new file mode 100644 index 00000000..3d971e8a --- /dev/null +++ b/test/config/vmess-server.json @@ -0,0 +1,25 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": 1234, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811", + "alterId": 0 + } + ] + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-ws-client.json b/test/config/vmess-ws-client.json new file mode 100644 index 00000000..532ef134 --- /dev/null +++ b/test/config/vmess-ws-client.json @@ -0,0 +1,52 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": "1080", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "protocol": "vmess", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 1234, + "users": [ + { + "id": "" + } + ] + } + ] + }, + "streamSettings": { + "network": "ws", + "security": "tls", + "tlsSettings": { + "serverName": "example.org", + "certificates": [ + { + "certificateFile": "/path/to/certificate.crt", + "keyFile": "/path/to/private.key" + } + ] + }, + "wsSettings": { + "maxEarlyData": 2048, + "earlyDataHeaderName": "" + } + } + } + ] +} \ No newline at end of file diff --git a/test/config/vmess-ws-server.json b/test/config/vmess-ws-server.json new file mode 100644 index 00000000..7eaef4ff --- /dev/null +++ b/test/config/vmess-ws-server.json @@ -0,0 +1,41 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": 1234, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811" + } + ] + }, + "streamSettings": { + "network": "ws", + "security": "tls", + "tlsSettings": { + "serverName": "example.org", + "certificates": [ + { + "certificateFile": "/path/to/certificate.crt", + "keyFile": "/path/to/private.key" + } + ] + }, + "wsSettings": { + "maxEarlyData": 2048, + "earlyDataHeaderName": "" + } + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} \ No newline at end of file diff --git a/test/config/wireguard.conf b/test/config/wireguard.conf new file mode 100644 index 00000000..b1140c31 --- /dev/null +++ b/test/config/wireguard.conf @@ -0,0 +1,8 @@ +[Interface] +PrivateKey = gHWUGzTh5YCEV6k8dneVP537XhVtoQJPIlFNs2zsxlE= +Address = 10.0.0.1/32 +ListenPort = 10000 + +[Peer] +PublicKey = LV2xr9tzxwbs0ZLUlFN9k/0Or9QWqIInvxc/Cu7/2hA= +AllowedIPs = 10.0.0.2/32 \ No newline at end of file diff --git a/test/direct_test.go b/test/direct_test.go new file mode 100644 index 00000000..d33defee --- /dev/null +++ b/test/direct_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +// Since this is a feature one-off added by outsiders, I won't address these anymore. +func _TestProxyProtocol(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeDirect, + Options: &option.DirectInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + ProxyProtocol: true, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeDirect, + Tag: "proxy-out", + Options: &option.DirectOutboundOptions{ + OverrideAddress: "127.0.0.1", + OverridePort: serverPort, + ProxyProtocol: 2, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "proxy-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/docker_test.go b/test/docker_test.go new file mode 100644 index 00000000..a85dd12c --- /dev/null +++ b/test/docker_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sagernet/sing/common/debug" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/rw" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/docker/go-connections/nat" + "github.com/stretchr/testify/require" +) + +type DockerOptions struct { + Image string + EntryPoint string + Ports []uint16 + Cmd []string + Env []string + Bind map[string]string + Stdin []byte + Cap []string +} + +func startDockerContainer(t *testing.T, options DockerOptions) { + dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + require.NoError(t, err) + defer dockerClient.Close() + + writeStdin := len(options.Stdin) > 0 + + var containerOptions container.Config + + if writeStdin { + containerOptions.OpenStdin = true + containerOptions.StdinOnce = true + } + + containerOptions.Image = options.Image + if options.EntryPoint != "" { + containerOptions.Entrypoint = []string{options.EntryPoint} + } + containerOptions.Cmd = options.Cmd + containerOptions.Env = options.Env + containerOptions.ExposedPorts = make(nat.PortSet) + + var hostOptions container.HostConfig + hostOptions.NetworkMode = "host" + hostOptions.CapAdd = options.Cap + hostOptions.PortBindings = make(nat.PortMap) + + for _, port := range options.Ports { + containerOptions.ExposedPorts[nat.Port(F.ToString(port, "/tcp"))] = struct{}{} + containerOptions.ExposedPorts[nat.Port(F.ToString(port, "/udp"))] = struct{}{} + hostOptions.PortBindings[nat.Port(F.ToString(port, "/tcp"))] = []nat.PortBinding{ + {HostPort: F.ToString(port), HostIP: "0.0.0.0"}, + } + hostOptions.PortBindings[nat.Port(F.ToString(port, "/udp"))] = []nat.PortBinding{ + {HostPort: F.ToString(port), HostIP: "0.0.0.0"}, + } + } + + if len(options.Bind) > 0 { + hostOptions.Binds = []string{} + for path, internalPath := range options.Bind { + if !rw.FileExists(path) { + path = filepath.Join("config", path) + } + path, _ = filepath.Abs(path) + hostOptions.Binds = append(hostOptions.Binds, path+":"+internalPath) + } + } + + dockerContainer, err := dockerClient.ContainerCreate(context.Background(), &containerOptions, &hostOptions, nil, nil, "") + require.NoError(t, err) + t.Cleanup(func() { + cleanContainer(dockerContainer.ID) + }) + + require.NoError(t, dockerClient.ContainerStart(context.Background(), dockerContainer.ID, container.StartOptions{})) + + if writeStdin { + stdinAttach, err := dockerClient.ContainerAttach(context.Background(), dockerContainer.ID, container.AttachOptions{ + Stdin: writeStdin, + Stream: true, + }) + require.NoError(t, err) + _, err = stdinAttach.Conn.Write(options.Stdin) + require.NoError(t, err) + stdinAttach.Close() + } + if debug.Enabled { + attach, err := dockerClient.ContainerAttach(context.Background(), dockerContainer.ID, container.AttachOptions{ + Stdout: true, + Stderr: true, + Logs: true, + Stream: true, + }) + require.NoError(t, err) + go func() { + stdcopy.StdCopy(os.Stderr, os.Stderr, attach.Reader) + }() + } + time.Sleep(time.Second) +} + +func cleanContainer(id string) error { + dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return err + } + defer dockerClient.Close() + return dockerClient.ContainerRemove(context.Background(), id, container.RemoveOptions{Force: true}) +} diff --git a/test/domain_inbound_test.go b/test/domain_inbound_test.go new file mode 100644 index 00000000..02354564 --- /dev/null +++ b/test/domain_inbound_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +func TestTUICDomainUDP(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTUIC, + Options: &option.TUICInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TUICUser{{ + UUID: uuid.Nil.String(), + }}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTUIC, + Tag: "tuic-out", + Options: &option.TUICOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: uuid.Nil.String(), + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "tuic-out", + }, + }, + }, + }, + }, + }, + }) + testQUIC(t, clientPort) +} diff --git a/test/ech_test.go b/test/ech_test.go new file mode 100644 index 00000000..91d13d3a --- /dev/null +++ b/test/ech_test.go @@ -0,0 +1,287 @@ +package main + +import ( + "net/netip" + "testing" + + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +func TestECH(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + echConfig, echKey := common.Must2(tls.ECHKeygenDefault("not.example.org")) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: "password", + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + ECH: &option.InboundECHOptions{ + Enabled: true, + Key: []string{echKey}, + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "trojan-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + ECH: &option.OutboundECHOptions{ + Enabled: true, + Config: []string{echConfig}, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "trojan-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestECHQUIC(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + echConfig, echKey := common.Must2(tls.ECHKeygenDefault("not.example.org")) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTUIC, + Options: &option.TUICInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TUICUser{{ + UUID: uuid.Nil.String(), + }}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + ECH: &option.InboundECHOptions{ + Enabled: true, + Key: []string{echKey}, + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTUIC, + Tag: "tuic-out", + Options: &option.TUICOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: uuid.Nil.String(), + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + ECH: &option.OutboundECHOptions{ + Enabled: true, + Config: []string{echConfig}, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "tuic-out", + }, + }, + }, + }, + }, + }, + }) + testSuitLargeUDP(t, clientPort, testPort) +} + +func TestECHHysteria2(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + echConfig, echKey := common.Must2(tls.ECHKeygenDefault("not.example.org")) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeHysteria2, + Options: &option.Hysteria2InboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.Hysteria2User{{ + Password: "password", + }}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + ECH: &option.InboundECHOptions{ + Enabled: true, + Key: []string{echKey}, + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeHysteria2, + Tag: "hy2-out", + Options: &option.Hysteria2OutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + ECH: &option.OutboundECHOptions{ + Enabled: true, + Config: []string{echConfig}, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "hy2-out", + }, + }, + }, + }, + }, + }, + }) + testSuitLargeUDP(t, clientPort, testPort) +} diff --git a/test/go.mod b/test/go.mod new file mode 100644 index 00000000..7d6d9edc --- /dev/null +++ b/test/go.mod @@ -0,0 +1,179 @@ +module test + +go 1.24.7 + +require github.com/sagernet/sing-box v0.0.0 + +replace github.com/sagernet/sing-box => ../ + +require ( + github.com/docker/docker v27.3.1+incompatible + github.com/docker/go-connections v0.5.0 + github.com/gofrs/uuid/v5 v5.4.0 + github.com/sagernet/quic-go v0.59.0-sing-box-mod.2 + github.com/sagernet/sing v0.8.0-beta.16 + github.com/sagernet/sing-quic v0.6.0-beta.11 + github.com/sagernet/sing-shadowsocks v0.2.8 + github.com/sagernet/sing-shadowsocks2 v0.2.1 + github.com/spyzhov/ajson v0.9.4 + github.com/stretchr/testify v1.11.1 + go.uber.org/goleak v1.3.0 + golang.org/x/net v0.48.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ajg/form v1.5.1 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.19.0 // indirect + github.com/anytls/sing-anytls v0.0.11 // indirect + github.com/caddyserver/certmagic v0.25.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect + github.com/cretz/bine v0.2.0 // indirect + github.com/database64128/netx-go v0.1.1 // indirect + github.com/database64128/tfo-go/v2 v2.3.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-chi/render v1.0.3 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.2.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/keybase/go-keychain v0.0.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/acmedns v0.5.0 // indirect + github.com/libdns/alidns v1.0.6-beta.3 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/metacubex/utls v1.8.4 // indirect + github.com/mholt/acmez/v3 v3.1.4 // indirect + github.com/miekg/dns v1.1.69 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/openai/openai-go/v3 v3.15.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect + github.com/sagernet/cors v1.2.1 // indirect + github.com/sagernet/cronet-go v0.0.0-20260117110918-dc1cda1fe287 // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260117110918-dc1cda1fe287 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260117110516-f21660bef13f // indirect + github.com/sagernet/fswatch v0.1.1 // indirect + github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 // indirect + github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect + github.com/sagernet/nftables v0.3.0-beta.4 // indirect + github.com/sagernet/sing-mux v0.3.4 // indirect + github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect + github.com/sagernet/sing-tun v0.8.0-beta.17 // indirect + github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect + github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 // indirect + github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288 // indirect + github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.40.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.1 // indirect + lukechampine.com/blake3 v1.3.0 // indirect +) diff --git a/test/go.sum b/test/go.sum new file mode 100644 index 00000000..34f8d997 --- /dev/null +++ b/test/go.sum @@ -0,0 +1,429 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= +github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= +github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= +github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= +github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= +github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= +github.com/database64128/tfo-go/v2 v2.3.1 h1:EGE+ELd5/AQ0X6YBlQ9RgKs8+kciNhgN3d8lRvfEJQw= +github.com/database64128/tfo-go/v2 v2.3.1/go.mod h1:k9wcpg/8i5zenspBkc9jUEYehpZZccBnCElzOJB++bU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= +github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= +github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= +github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= +github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 h1:MEufgJohwIjFi2n3eJv4c/8UdRLQVUwPwSWQPoER+eU= +github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= +github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= +github.com/libdns/alidns v1.0.6-beta.3 h1:KAmb7FQ1tRzKsaAUGa7ZpGKAMRANwg7+1c7tUbSELq8= +github.com/libdns/alidns v1.0.6-beta.3/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ= +github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/openai/openai-go/v3 v3.15.0 h1:hk99rM7YPz+M99/5B/zOQcVwFRLLMdprVGx1vaZ8XMo= +github.com/openai/openai-go/v3 v3.15.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= +github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= +github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= +github.com/sagernet/cronet-go v0.0.0-20260117110918-dc1cda1fe287 h1:0BYNmr0ptjsII948U0oBFmrbo4qEaCFcrE2JPRg3Zlk= +github.com/sagernet/cronet-go v0.0.0-20260117110918-dc1cda1fe287/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go/all v0.0.0-20260117110918-dc1cda1fe287 h1:ghxhYSBQpzkakqWqJDvXr/Zmxe0WjTjKuALEGbjGiGY= +github.com/sagernet/cronet-go/all v0.0.0-20260117110918-dc1cda1fe287/go.mod h1:M+4ZjPhLJXIvoxcQsbDofmc19Wrig59hZ+hLvj6S3To= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260117110516-f21660bef13f h1:8jZbZ4KBTdcXDFLwUBNQt5Xci6ZuAKh255S8TwuBCaM= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260117110516-f21660bef13f/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260117110516-f21660bef13f h1:tG0hCx+0u5zca7qQ7AMkcv4DCrBG/DKW1ggs/P+BRRI= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260117110516-f21660bef13f h1:ZXp5hKJIA7iJ52ZShJCKMQEPLpp/7dDIVZmPGV9Il40= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260117110516-f21660bef13f/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260117110516-f21660bef13f h1:gL7H8HS8s38adz4/HZtRHh79qMwsbLTRRPz4GQ9LcWI= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260117110516-f21660bef13f h1:Dchgc0pAY5Jwb5lzUlE+1nhHIzqLx+YOurXLHgvWd/0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260117110516-f21660bef13f h1:+MOLSQoduuKDxF410i1LcSPaQGaiP0eZb0INvMlmjM4= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260117110516-f21660bef13f h1:lIZna05Vn6n8k21p8OpSUnhwGm+E57PrMjiI4ZUfMSg= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260117110516-f21660bef13f h1:B2aFQ5CRHI20t8YsEizvtguS5W2QfK7D5XV/NzTIxPE= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260117110516-f21660bef13f h1:qpSwJ1rFGYCfJDenNCZoWYjoG7N+xEa6ke+E7/JO1i4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260117110516-f21660bef13f h1:cx7Ipg0tSvTDjS4maMEYz4vuzz93BMPAysmZ1YLrz80= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260117110516-f21660bef13f/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260117110516-f21660bef13f h1:4jOHuUiBxD8pJEpBBVQfJqyLmxjpd3t4MLRzU7YLFyg= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260117110516-f21660bef13f h1:OpXBa2WlRU+Mam9oRe9Nn4/zf7gQ+qiBTNK8A5RwbfQ= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260117110516-f21660bef13f h1:nJpGFi+6hI85tl4zoyNFEnFEQ5+xEV5gyvsUoMvd8g0= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260117110516-f21660bef13f h1:SEy2rpmgOJgrqcEryJI/RSnqUWIsEsp0cfYoA8y21jc= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260117110516-f21660bef13f/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260117110516-f21660bef13f h1:EW2TuFMLm0iBGqRZtuGwIZdeYmDtDsDmRcRRJQOMxUo= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260117110516-f21660bef13f h1:3U5woxrNCkzfv1+UX+mVoWh1228AE1qAiMG02F9oFbY= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260117110516-f21660bef13f h1:YwFTfuWG3mmctroeDYtFZ6LHjGsedVO+5wInYbbUuUY= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260117110516-f21660bef13f h1:r4V0ddPCRLgGu0VdgR3aUsO9NjpmyjAf+h+3oTD9D6E= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260117110516-f21660bef13f h1:B8yf4gFvEYUnwWmtVK9sdwUsflYZ387MhYmlOP2ohFQ= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260117110516-f21660bef13f h1:9YyaMg4rO1/jIgrxmNb0LKH+X7frSYWfX2pFgW5JUVM= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260117110516-f21660bef13f h1:B0fnGu0sh9yT/9JDN5u/GqThGoOzNN/daOAuGWFLXEk= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260117110516-f21660bef13f h1:lxPcIXKSSI5JDhc7rx/6yufISWM4vtBS2FY9PavWQTs= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= +github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= +github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 h1:SUPFNB+vSP4RBPrSEgNII+HkfqC8hKMpYLodom4o4EU= +github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= +github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= +github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.2 h1:hJUL+HtxEOjxsa0CsucbBVqI/AMS4k52NwNU637zmdw= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.2/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= +github.com/sagernet/sing v0.8.0-beta.16 h1:Fe+6E9VHYky9Mx4cf0ugbZPWDcXRflpAu7JQ5bWXvaA= +github.com/sagernet/sing v0.8.0-beta.16/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= +github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-quic v0.6.0-beta.11 h1:eUusxITKKRedhWC2ScUYFUvD96h/QfbKLaS3N6/7in4= +github.com/sagernet/sing-quic v0.6.0-beta.11/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= +github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= +github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= +github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= +github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-tun v0.8.0-beta.17 h1:6DdbNXeTFYj8Tb4FCh8Mp2boA3rVY6VNqzTOObj7Xis= +github.com/sagernet/sing-tun v0.8.0-beta.17/go.mod h1:+HAK/y9GZljdT0KYKMYDR8MjjqnqDDQZYp5ZZQoRzS8= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= +github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= +github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 h1:eYz/OpMqWCvO2++iw3dEuzrlfC2xv78GdlGvprIM6O8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288 h1:E2tZFeg9mGYGQ7E7BbxMv1cU35HxwgRm6tPKI2Pp7DA= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spyzhov/ajson v0.9.4 h1:MVibcTCgO7DY4IlskdqIlCmDOsUOZ9P7oKj8ifdcf84= +github.com/spyzhov/ajson v0.9.4/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= +lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/test/http_test.go b/test/http_test.go new file mode 100644 index 00000000..0e9185ef --- /dev/null +++ b/test/http_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestHTTPSelf(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeHTTP, + Tag: "http-out", + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "http-out", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} diff --git a/test/hysteria2_test.go b/test/hysteria2_test.go new file mode 100644 index 00000000..115af4a7 --- /dev/null +++ b/test/hysteria2_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "net/netip" + "testing" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic/hysteria2" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestHysteria2Self(t *testing.T) { + t.Run("self", func(t *testing.T) { + testHysteria2Self(t, "", false) + }) + t.Run("self-salamander", func(t *testing.T) { + testHysteria2Self(t, "password", false) + }) + t.Run("self-hop", func(t *testing.T) { + testHysteria2Self(t, "", true) + }) + t.Run("self-hop-salamander", func(t *testing.T) { + testHysteria2Self(t, "password", true) + }) +} + +func TestHysteria2Hop(t *testing.T) { + testHysteria2Self(t, "password", true) +} + +func testHysteria2Self(t *testing.T, salamanderPassword string, portHop bool) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + var obfs *option.Hysteria2Obfs + if salamanderPassword != "" { + obfs = &option.Hysteria2Obfs{ + Type: hysteria2.ObfsTypeSalamander, + Password: salamanderPassword, + } + } + var ( + serverPorts []string + hopInterval time.Duration + ) + if portHop { + serverPorts = []string{F.ToString(serverPort, ":", serverPort)} + hopInterval = 5 * time.Second + } + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeHysteria2, + Options: &option.Hysteria2InboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + UpMbps: 100, + DownMbps: 100, + Obfs: obfs, + Users: []option.Hysteria2User{{ + Password: "password", + }}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeHysteria2, + Tag: "hy2-out", + Options: &option.Hysteria2OutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + ServerPorts: serverPorts, + HopInterval: badoption.Duration(hopInterval), + UpMbps: 100, + DownMbps: 100, + Obfs: obfs, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "hy2-out", + }, + }, + }, + }, + }, + }, + }) + testSuitLargeUDP(t, clientPort, testPort) + if portHop { + time.Sleep(5 * time.Second) + testSuitLargeUDP(t, clientPort, testPort) + } +} + +func TestHysteria2Inbound(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHysteria2, + Options: &option.Hysteria2InboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Obfs: &option.Hysteria2Obfs{ + Type: hysteria2.ObfsTypeSalamander, + Password: "cry_me_a_r1ver", + }, + Users: []option.Hysteria2User{{ + Password: "password", + }}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageHysteria2, + Ports: []uint16{serverPort, clientPort}, + Cmd: []string{"client", "-c", "/etc/hysteria/config.yml", "--disable-update-check", "--log-level", "debug"}, + Bind: map[string]string{ + "hysteria2-client.yml": "/etc/hysteria/config.yml", + caPem: "/etc/hysteria/ca.pem", + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestHysteria2Outbound(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startDockerContainer(t, DockerOptions{ + Image: ImageHysteria2, + Ports: []uint16{testPort}, + Cmd: []string{"server", "-c", "/etc/hysteria/config.yml", "--disable-update-check", "--log-level", "debug"}, + Bind: map[string]string{ + "hysteria2-server.yml": "/etc/hysteria/config.yml", + certPem: "/etc/hysteria/cert.pem", + keyPem: "/etc/hysteria/key.pem", + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHysteria2, + Options: &option.Hysteria2OutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Obfs: &option.Hysteria2Obfs{ + Type: hysteria2.ObfsTypeSalamander, + Password: "cry_me_a_r1ver", + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + testSuitSimple1(t, clientPort, testPort) +} diff --git a/test/hysteria_test.go b/test/hysteria_test.go new file mode 100644 index 00000000..a7f73dea --- /dev/null +++ b/test/hysteria_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestHysteriaSelf(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeHysteria, + Options: &option.HysteriaInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + UpMbps: 100, + DownMbps: 100, + Users: []option.HysteriaUser{{ + AuthString: "password", + }}, + Obfs: "fuck me till the daylight", + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeHysteria, + Tag: "hy-out", + Options: &option.HysteriaOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UpMbps: 100, + DownMbps: 100, + AuthString: "password", + Obfs: "fuck me till the daylight", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "hy-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestHysteriaInbound(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHysteria, + Options: &option.HysteriaInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + UpMbps: 100, + DownMbps: 100, + Users: []option.HysteriaUser{{ + AuthString: "password", + }}, + Obfs: "fuck me till the daylight", + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageHysteria, + Ports: []uint16{serverPort, clientPort}, + Cmd: []string{"-c", "/etc/hysteria/config.json", "client"}, + Bind: map[string]string{ + "hysteria-client.json": "/etc/hysteria/config.json", + caPem: "/etc/hysteria/ca.pem", + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestHysteriaOutbound(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startDockerContainer(t, DockerOptions{ + Image: ImageHysteria, + Ports: []uint16{testPort}, + Cmd: []string{"-c", "/etc/hysteria/config.json", "server"}, + Bind: map[string]string{ + "hysteria-server.json": "/etc/hysteria/config.json", + certPem: "/etc/hysteria/cert.pem", + keyPem: "/etc/hysteria/key.pem", + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHysteria, + Options: &option.HysteriaOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UpMbps: 100, + DownMbps: 100, + AuthString: "password", + Obfs: "fuck me till the daylight", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + testSuitSimple1(t, clientPort, testPort) +} diff --git a/test/inbound_detour_test.go b/test/inbound_detour_test.go new file mode 100644 index 00000000..f4043895 --- /dev/null +++ b/test/inbound_detour_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestChainedInbound(t *testing.T) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + Detour: "detour", + }, + Method: method, + Password: password, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "detour", + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: otherPort, + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + Method: method, + Password: password, + DialerOptions: option.DialerOptions{ + Detour: "detour-out", + }, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "detour-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} diff --git a/test/ktls_test.go b/test/ktls_test.go new file mode 100644 index 00000000..c873a162 --- /dev/null +++ b/test/ktls_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "net/netip" + "testing" + + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +func TestKTLS(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: "password", + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + // KernelTx: true, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "trojan-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KernelTx: true, + KernelRx: true, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "trojan-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestKTLSECH(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + echConfig, echKey := common.Must2(tls.ECHKeygenDefault("not.example.org")) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: "password", + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + KernelTx: true, + ECH: &option.InboundECHOptions{ + Enabled: true, + Key: []string{echKey}, + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "trojan-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KernelTx: true, + KernelRx: true, + ECH: &option.OutboundECHOptions{ + Enabled: true, + Config: []string{echConfig}, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "trojan-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestKTLSReality(t *testing.T) { + user, _ := uuid.NewV4() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVLESS, + Options: &option.VLESSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VLESSUser{{UUID: user.String()}}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + KernelTx: true, + Reality: &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "google.com", + ServerPort: 443, + }, + }, + ShortID: []string{"0123456789abcdef"}, + PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVLESS, + Tag: "ss-out", + Options: &option.VLESSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + KernelTx: true, + KernelRx: true, + Reality: &option.OutboundRealityOptions{ + Enabled: true, + ShortID: "0123456789abcdef", + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + }, + UTLS: &option.OutboundUTLSOptions{ + Enabled: true, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/mkcert.go b/test/mkcert.go new file mode 100644 index 00000000..2d35ff84 --- /dev/null +++ b/test/mkcert.go @@ -0,0 +1,88 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sagernet/sing/common/rw" + + "github.com/stretchr/testify/require" +) + +func createSelfSignedCertificate(t *testing.T, domain string) (caPem, certPem, keyPem string) { + const userAndHostname = "sekai@nekohasekai.local" + tempDir, err := os.MkdirTemp("", "sing-box-test") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(tempDir) + }) + caKey, err := rsa.GenerateKey(rand.Reader, 3072) + require.NoError(t, err) + spkiASN1, err := x509.MarshalPKIXPublicKey(caKey.Public()) + var spki struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString + } + _, err = asn1.Unmarshal(spkiASN1, &spki) + require.NoError(t, err) + skid := sha1.Sum(spki.SubjectPublicKey.Bytes) + caTpl := &x509.Certificate{ + SerialNumber: randomSerialNumber(t), + Subject: pkix.Name{ + Organization: []string{"sing-box test CA"}, + OrganizationalUnit: []string{userAndHostname}, + CommonName: "sing-box " + userAndHostname, + }, + SubjectKeyId: skid[:], + NotAfter: time.Now().AddDate(10, 0, 0), + NotBefore: time.Now(), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + caCert, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, caKey.Public(), caKey) + require.NoError(t, err) + err = rw.WriteFile(filepath.Join(tempDir, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert})) + require.NoError(t, err) + key, err := rsa.GenerateKey(rand.Reader, 2048) + domainTpl := &x509.Certificate{ + SerialNumber: randomSerialNumber(t), + Subject: pkix.Name{ + Organization: []string{"sing-box test certificate"}, + OrganizationalUnit: []string{"sing-box " + userAndHostname}, + }, + NotBefore: time.Now(), NotAfter: time.Now().AddDate(0, 0, 30), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + domainTpl.DNSNames = append(domainTpl.DNSNames, domain) + cert, err := x509.CreateCertificate(rand.Reader, domainTpl, caTpl, key.Public(), caKey) + require.NoError(t, err) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert}) + privDER, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER}) + err = rw.WriteFile(filepath.Join(tempDir, domain+".pem"), certPEM) + require.NoError(t, err) + err = rw.WriteFile(filepath.Join(tempDir, domain+".key.pem"), privPEM) + require.NoError(t, err) + return filepath.Join(tempDir, "ca.pem"), filepath.Join(tempDir, domain+".pem"), filepath.Join(tempDir, domain+".key.pem") +} + +func randomSerialNumber(t *testing.T) *big.Int { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + require.NoError(t, err) + return serialNumber +} diff --git a/test/mux_cool_test.go b/test/mux_cool_test.go new file mode 100644 index 00000000..ed42a059 --- /dev/null +++ b/test/mux_cool_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "net/netip" + "os" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/spyzhov/ajson" + "github.com/stretchr/testify/require" +) + +func TestMuxCoolServer(t *testing.T) { + userId := newUUID() + content, err := os.ReadFile("config/vmess-mux-client.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + config.MustKey("inbounds").MustIndex(0).MustKey("port").SetNumeric(float64(clientPort)) + outbound := config.MustKey("outbounds").MustIndex(0).MustKey("settings").MustKey("vnext").MustIndex(0) + outbound.MustKey("port").SetNumeric(float64(serverPort)) + user := outbound.MustKey("users").MustIndex(0) + user.MustKey("id").SetString(userId.String()) + + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + }) + + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: userId.String(), + }, + }, + }, + }, + }, + }) + + testSuitSimple(t, clientPort, testPort) +} + +func TestMuxCoolClient(t *testing.T) { + user := newUUID() + content, err := os.ReadFile("config/vmess-server.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + inbound := config.MustKey("inbounds").MustIndex(0) + inbound.MustKey("port").SetNumeric(float64(serverPort)) + inbound.MustKey("settings").MustKey("clients").MustIndex(0).MustKey("id").SetString(user.String()) + + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageXRayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "xray", + Stdin: content, + }) + + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeVMess, + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + PacketEncoding: "xudp", + }, + }, + }, + }) + testSuitSimple(t, clientPort, testPort) +} + +func TestMuxCoolSelf(t *testing.T) { + user := newUUID() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: user.String(), + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + PacketEncoding: "xudp", + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuitSimple(t, clientPort, testPort) +} diff --git a/test/mux_test.go b/test/mux_test.go new file mode 100644 index 00000000..6454d19c --- /dev/null +++ b/test/mux_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +var muxProtocols = []string{ + "h2mux", + "smux", + "yamux", +} + +func TestVMessSMux(t *testing.T) { + testVMessMux(t, option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "smux", + }) +} + +func TestShadowsocksMux(t *testing.T) { + for _, protocol := range muxProtocols { + t.Run(protocol, func(t *testing.T) { + testShadowsocksMux(t, option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: protocol, + }) + }) + } +} + +func TestShadowsockH2Mux(t *testing.T) { + testShadowsocksMux(t, option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "h2mux", + Padding: true, + }) +} + +func TestShadowsockSMuxPadding(t *testing.T) { + testShadowsocksMux(t, option.OutboundMultiplexOptions{ + Enabled: true, + Protocol: "smux", + Padding: true, + }) +} + +func testShadowsocksMux(t *testing.T, options option.OutboundMultiplexOptions) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Method: method, + Password: password, + Multiplex: &option.InboundMultiplexOptions{ + Enabled: true, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password, + Multiplex: &options, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func testVMessMux(t *testing.T, options option.OutboundMultiplexOptions) { + user, _ := uuid.NewV4() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + UUID: user.String(), + }, + }, + Multiplex: &option.InboundMultiplexOptions{ + Enabled: true, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Security: "auto", + UUID: user.String(), + Multiplex: &options, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/naive_self_test.go b/test/naive_self_test.go new file mode 100644 index 00000000..9d293bfb --- /dev/null +++ b/test/naive_self_test.go @@ -0,0 +1,533 @@ +//go:build with_naive_outbound + +package main + +import ( + "net/netip" + "os" + "strings" + "testing" + + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" +) + +func TestNaiveSelf(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + caPemContent, err := os.ReadFile(caPem) + require.NoError(t, err) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeNaive, + Tag: "naive-in", + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeNaive, + Tag: "naive-out", + Options: &option.NaiveOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + Certificate: []string{string(caPemContent)}, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "naive-out", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestNaiveSelfECH(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + caPemContent, err := os.ReadFile(caPem) + require.NoError(t, err) + echConfig, echKey := common.Must2(tls.ECHKeygenDefault("not.example.org")) + instance := startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeNaive, + Tag: "naive-in", + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + ECH: &option.InboundECHOptions{ + Enabled: true, + Key: []string{echKey}, + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeNaive, + Tag: "naive-out", + Options: &option.NaiveOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + Certificate: []string{string(caPemContent)}, + ECH: &option.OutboundECHOptions{ + Enabled: true, + Config: []string{echConfig}, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "naive-out", + }, + }, + }, + }, + }, + }, + }) + + naiveOut, ok := instance.Outbound().Outbound("naive-out") + require.True(t, ok) + naiveOutbound := naiveOut.(*naive.Outbound) + + netLogPath := "/tmp/naive_ech_netlog.json" + require.True(t, naiveOutbound.Client().Engine().StartNetLogToFile(netLogPath, true)) + defer naiveOutbound.Client().Engine().StopNetLog() + + testTCP(t, clientPort, testPort) + + naiveOutbound.Client().Engine().StopNetLog() + + logContent, err := os.ReadFile(netLogPath) + require.NoError(t, err) + logStr := string(logContent) + + require.True(t, strings.Contains(logStr, `"encrypted_client_hello":true`), + "ECH should be accepted in TLS handshake. NetLog saved to: %s", netLogPath) +} + +func TestNaiveSelfInsecureConcurrency(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + caPemContent, err := os.ReadFile(caPem) + require.NoError(t, err) + + instance := startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeNaive, + Tag: "naive-in", + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeNaive, + Tag: "naive-out", + Options: &option.NaiveOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + InsecureConcurrency: 3, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + Certificate: []string{string(caPemContent)}, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "naive-out", + }, + }, + }, + }, + }, + }, + }) + + naiveOut, ok := instance.Outbound().Outbound("naive-out") + require.True(t, ok) + naiveOutbound := naiveOut.(*naive.Outbound) + + netLogPath := "/tmp/naive_concurrency_netlog.json" + require.True(t, naiveOutbound.Client().Engine().StartNetLogToFile(netLogPath, true)) + defer naiveOutbound.Client().Engine().StopNetLog() + + // Send multiple sequential connections to trigger round-robin + // With insecure_concurrency=3, connections will be distributed to 3 pools + for i := 0; i < 6; i++ { + testTCP(t, clientPort, testPort) + } + + naiveOutbound.Client().Engine().StopNetLog() + + // Verify NetLog contains multiple independent HTTP/2 sessions + logContent, err := os.ReadFile(netLogPath) + require.NoError(t, err) + logStr := string(logContent) + + // Count HTTP2_SESSION_INITIALIZED events to verify connection pool isolation + // NetLog stores event types as numeric IDs, HTTP2_SESSION_INITIALIZED = 249 + sessionCount := strings.Count(logStr, `"type":249`) + require.GreaterOrEqual(t, sessionCount, 3, + "Expected at least 3 HTTP/2 sessions with insecure_concurrency=3. NetLog: %s", netLogPath) +} + +func TestNaiveSelfQUIC(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + caPemContent, err := os.ReadFile(caPem) + require.NoError(t, err) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeNaive, + Tag: "naive-in", + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkUDP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeNaive, + Tag: "naive-out", + Options: &option.NaiveOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + QUIC: true, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + Certificate: []string{string(caPemContent)}, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "naive-out", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestNaiveSelfQUICCongestionControl(t *testing.T) { + testCases := []struct { + name string + congestionControl string + }{ + {"BBR", "bbr"}, + {"BBR2", "bbr2"}, + {"Cubic", "cubic"}, + {"Reno", "reno"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + caPemContent, err := os.ReadFile(caPem) + require.NoError(t, err) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeNaive, + Tag: "naive-in", + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkUDP, + QUICCongestionControl: tc.congestionControl, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeNaive, + Tag: "naive-out", + Options: &option.NaiveOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + QUIC: true, + QUICCongestionControl: tc.congestionControl, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + Certificate: []string{string(caPemContent)}, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "naive-out", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) + }) + } +} diff --git a/test/naive_test.go b/test/naive_test.go new file mode 100644 index 00000000..9dbe9855 --- /dev/null +++ b/test/naive_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/network" +) + +func TestNaiveInboundWithNginx(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeNaive, + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: otherPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + }, + }, + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageNginx, + Ports: []uint16{serverPort, otherPort}, + Bind: map[string]string{ + "nginx.conf": "/etc/nginx/nginx.conf", + "naive-nginx.conf": "/etc/nginx/conf.d/naive.conf", + certPem: "/etc/nginx/cert.pem", + keyPem: "/etc/nginx/key.pem", + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageNaive, + Ports: []uint16{serverPort, clientPort}, + Bind: map[string]string{ + "naive.json": "/etc/naiveproxy/config.json", + caPem: "/etc/naiveproxy/ca.pem", + }, + Env: []string{ + "SSL_CERT_FILE=/etc/naiveproxy/ca.pem", + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestNaiveInbound(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeNaive, + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageNaive, + Ports: []uint16{serverPort, clientPort}, + Bind: map[string]string{ + "naive.json": "/etc/naiveproxy/config.json", + caPem: "/etc/naiveproxy/ca.pem", + }, + Env: []string{ + "SSL_CERT_FILE=/etc/naiveproxy/ca.pem", + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestNaiveHTTP3Inbound(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeNaive, + Options: &option.NaiveInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkUDP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageNaive, + Ports: []uint16{serverPort, clientPort}, + Bind: map[string]string{ + "naive-quic.json": "/etc/naiveproxy/config.json", + caPem: "/etc/naiveproxy/ca.pem", + }, + Env: []string{ + "SSL_CERT_FILE=/etc/naiveproxy/ca.pem", + }, + }) + testTCP(t, clientPort, testPort) +} diff --git a/test/reality_test.go b/test/reality_test.go new file mode 100644 index 00000000..220ccb6c --- /dev/null +++ b/test/reality_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +func TestReality(t *testing.T) { + user, _ := uuid.NewV4() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVLESS, + Options: &option.VLESSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VLESSUser{{UUID: user.String()}}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + Reality: &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "google.com", + ServerPort: 443, + }, + }, + ShortID: []string{"0123456789abcdef"}, + PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + }, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVLESS, + Tag: "ss-out", + Options: &option.VLESSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + Reality: &option.OutboundRealityOptions{ + Enabled: true, + ShortID: "0123456789abcdef", + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + }, + UTLS: &option.OutboundUTLSOptions{ + Enabled: true, + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/shadowsocks_legacy_test.go b/test/shadowsocks_legacy_test.go new file mode 100644 index 00000000..8182e6cb --- /dev/null +++ b/test/shadowsocks_legacy_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks2/shadowstream" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestShadowsocksLegacy(t *testing.T) { + testShadowsocksLegacy(t, shadowstream.MethodList[0]) +} + +func testShadowsocksLegacy(t *testing.T, method string) { + startDockerContainer(t, DockerOptions{ + Image: ImageShadowsocksLegacy, + Ports: []uint16{serverPort}, + Env: []string{ + "SS_MODULE=ss-server", + F.ToString("SS_CONFIG=-s 0.0.0.0 -u -p 10000 -m ", method, " -k FzcLbKs2dY9mhL"), + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: "FzcLbKs2dY9mhL", + }, + }, + }, + }) + testSuitSimple(t, clientPort, testPort) +} diff --git a/test/shadowsocks_test.go b/test/shadowsocks_test.go new file mode 100644 index 00000000..3f526b10 --- /dev/null +++ b/test/shadowsocks_test.go @@ -0,0 +1,367 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/stretchr/testify/require" +) + +const ( + serverPort uint16 = 10000 + iota + clientPort + testPort + otherPort + otherClientPort +) + +func TestShadowsocks(t *testing.T) { + for _, method := range []string{ + "aes-128-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + } { + t.Run(method+"-inbound", func(t *testing.T) { + testShadowsocksInboundWithShadowsocksRust(t, method, mkBase64(t, 16)) + }) + t.Run(method+"-outbound", func(t *testing.T) { + testShadowsocksOutboundWithShadowsocksRust(t, method, mkBase64(t, 16)) + }) + t.Run(method+"-self", func(t *testing.T) { + testShadowsocksSelf(t, method, mkBase64(t, 16)) + }) + } +} + +func TestShadowsocksNone(t *testing.T) { + testShadowsocksSelf(t, "none", "") +} + +func TestShadowsocks2022(t *testing.T) { + for _, method16 := range []string{ + "2022-blake3-aes-128-gcm", + } { + t.Run(method16+"-inbound", func(t *testing.T) { + testShadowsocksInboundWithShadowsocksRust(t, method16, mkBase64(t, 16)) + }) + t.Run(method16+"-outbound", func(t *testing.T) { + testShadowsocksOutboundWithShadowsocksRust(t, method16, mkBase64(t, 16)) + }) + t.Run(method16+"-self", func(t *testing.T) { + testShadowsocksSelf(t, method16, mkBase64(t, 16)) + }) + } + for _, method32 := range []string{ + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305", + } { + t.Run(method32+"-inbound", func(t *testing.T) { + testShadowsocksInboundWithShadowsocksRust(t, method32, mkBase64(t, 32)) + }) + t.Run(method32+"-outbound", func(t *testing.T) { + testShadowsocksOutboundWithShadowsocksRust(t, method32, mkBase64(t, 32)) + }) + t.Run(method32+"-self", func(t *testing.T) { + testShadowsocksSelf(t, method32, mkBase64(t, 32)) + }) + } +} + +func TestShadowsocks2022EIH(t *testing.T) { + for _, method16 := range []string{ + "2022-blake3-aes-128-gcm", + } { + t.Run(method16, func(t *testing.T) { + testShadowsocks2022EIH(t, method16, mkBase64(t, 16)) + }) + } + for _, method32 := range []string{ + "2022-blake3-aes-256-gcm", + } { + t.Run(method32, func(t *testing.T) { + testShadowsocks2022EIH(t, method32, mkBase64(t, 32)) + }) + } +} + +func testShadowsocksInboundWithShadowsocksRust(t *testing.T, method string, password string) { + startDockerContainer(t, DockerOptions{ + Image: ImageShadowsocksRustClient, + EntryPoint: "sslocal", + Ports: []uint16{serverPort, clientPort}, + Cmd: []string{"-s", F.ToString("127.0.0.1:", serverPort), "-b", F.ToString("0.0.0.0:", clientPort), "-m", method, "-k", password, "-U"}, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Method: method, + Password: password, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func testShadowsocksOutboundWithShadowsocksRust(t *testing.T, method string, password string) { + startDockerContainer(t, DockerOptions{ + Image: ImageShadowsocksRustServer, + EntryPoint: "ssserver", + Ports: []uint16{serverPort, testPort}, + Cmd: []string{"-s", F.ToString("0.0.0.0:", serverPort), "-m", method, "-k", password, "-U"}, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func testShadowsocksSelf(t *testing.T, method string, password string) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestShadowsocksUoT(t *testing.T) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password, + UDPOverTCP: &option.UDPOverTCPOptions{ + Enabled: true, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func testShadowsocks2022EIH(t *testing.T, method string, password string) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Method: method, + Password: password, + Users: []option.ShadowsocksUser{ + { + Password: password, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: method, + Password: password + ":" + password, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func mkBase64(t *testing.T, length int) string { + psk := make([]byte, length) + _, err := rand.Read(psk) + require.NoError(t, err) + return base64.StdEncoding.EncodeToString(psk) +} diff --git a/test/shadowtls_test.go b/test/shadowtls_test.go new file mode 100644 index 00000000..6c4b71d4 --- /dev/null +++ b/test/shadowtls_test.go @@ -0,0 +1,498 @@ +package main + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/stretchr/testify/require" +) + +func TestShadowTLS(t *testing.T) { + t.Run("v1", func(t *testing.T) { + testShadowTLS(t, 1, "", false, option.ShadowTLSWildcardSNIOff) + }) + t.Run("v2", func(t *testing.T) { + testShadowTLS(t, 2, "hello", false, option.ShadowTLSWildcardSNIOff) + }) + t.Run("v3", func(t *testing.T) { + testShadowTLS(t, 3, "hello", false, option.ShadowTLSWildcardSNIOff) + }) + t.Run("v2-utls", func(t *testing.T) { + testShadowTLS(t, 2, "hello", true, option.ShadowTLSWildcardSNIOff) + }) + t.Run("v3-utls", func(t *testing.T) { + testShadowTLS(t, 3, "hello", true, option.ShadowTLSWildcardSNIOff) + }) + t.Run("v3-wildcard-sni-authed", func(t *testing.T) { + testShadowTLS(t, 3, "hello", false, option.ShadowTLSWildcardSNIAuthed) + }) + t.Run("v3-wildcard-sni-all", func(t *testing.T) { + testShadowTLS(t, 3, "hello", false, option.ShadowTLSWildcardSNIAll) + }) + t.Run("v3-wildcard-sni-authed-utls", func(t *testing.T) { + testShadowTLS(t, 3, "hello", true, option.ShadowTLSWildcardSNIAll) + }) + t.Run("v3-wildcard-sni-all-utls", func(t *testing.T) { + testShadowTLS(t, 3, "hello", true, option.ShadowTLSWildcardSNIAll) + }) +} + +func testShadowTLS(t *testing.T, version int, password string, utlsEanbled bool, wildcardSNI option.WildcardSNI) { + method := shadowaead_2022.List[0] + ssPassword := mkBase64(t, 16) + var clientServerName string + if wildcardSNI != option.ShadowTLSWildcardSNIOff { + clientServerName = "cloudflare.com" + } else { + clientServerName = "google.com" + } + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowTLS, + Tag: "in", + Options: &option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + Detour: "detour", + }, + Handshake: option.ShadowTLSHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "google.com", + ServerPort: 443, + }, + }, + Version: version, + Password: password, + Users: []option.ShadowTLSUser{{Password: password}}, + WildcardSNI: wildcardSNI, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "detour", + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: otherPort, + }, + Method: method, + Password: ssPassword, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksOutboundOptions{ + Method: method, + Password: ssPassword, + DialerOptions: option.DialerOptions{ + Detour: "detour", + }, + }, + }, + { + Type: C.TypeShadowTLS, + Tag: "detour", + Options: &option.ShadowTLSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: clientServerName, + UTLS: &option.OutboundUTLSOptions{ + Enabled: utlsEanbled, + }, + }, + }, + Version: version, + Password: password, + }, + }, + { + Type: C.TypeDirect, + Tag: "direct", + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"detour"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "direct", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestShadowTLSFallback(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeShadowTLS, + Options: &option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Handshake: option.ShadowTLSHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "bing.com", + ServerPort: 443, + }, + }, + Version: 3, + Users: []option.ShadowTLSUser{ + {Password: "hello"}, + }, + }, + }, + }, + }) + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, "127.0.0.1:"+F.ToString(serverPort)) + }, + }, + } + response, err := client.Get("https://bing.com") + require.NoError(t, err) + require.Equal(t, response.StatusCode, 200) + response.Body.Close() + client.CloseIdleConnections() +} + +func TestShadowTLSFallbackWildcardAll(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeShadowTLS, + Options: &option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Version: 3, + Users: []option.ShadowTLSUser{ + {Password: "hello"}, + }, + WildcardSNI: option.ShadowTLSWildcardSNIAll, + }, + }, + }, + }) + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, "127.0.0.1:"+F.ToString(serverPort)) + }, + }, + } + response, err := client.Get("https://www.bing.com") + require.NoError(t, err) + require.Equal(t, response.StatusCode, 200) + response.Body.Close() + client.CloseIdleConnections() +} + +func TestShadowTLSFallbackWildcardAuthedFail(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeShadowTLS, + Options: &option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Handshake: option.ShadowTLSHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "bing.com", + ServerPort: 443, + }, + }, + Version: 3, + Users: []option.ShadowTLSUser{ + {Password: "hello"}, + }, + WildcardSNI: option.ShadowTLSWildcardSNIAuthed, + }, + }, + }, + }) + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, "127.0.0.1:"+F.ToString(serverPort)) + }, + }, + } + _, err := client.Get("https://baidu.com") + expected := &tls.CertificateVerificationError{} + require.ErrorAs(t, err, &expected) + client.CloseIdleConnections() +} + +func TestShadowTLSFallbackWildcardOffFail(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeShadowTLS, + Options: &option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Handshake: option.ShadowTLSHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "bing.com", + ServerPort: 443, + }, + }, + Version: 3, + Users: []option.ShadowTLSUser{ + {Password: "hello"}, + }, + WildcardSNI: option.ShadowTLSWildcardSNIOff, + }, + }, + }, + }) + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, "127.0.0.1:"+F.ToString(serverPort)) + }, + }, + } + _, err := client.Get("https://baidu.com") + expected := &tls.CertificateVerificationError{} + require.ErrorAs(t, err, &expected) + client.CloseIdleConnections() +} + +func TestShadowTLSInbound(t *testing.T) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startDockerContainer(t, DockerOptions{ + Image: ImageShadowTLS, + Ports: []uint16{serverPort, otherPort}, + EntryPoint: "shadow-tls", + Cmd: []string{"--v3", "--threads", "1", "client", "--listen", "0.0.0.0:" + F.ToString(otherPort), "--server", "127.0.0.1:" + F.ToString(serverPort), "--sni", "google.com", "--password", password}, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowTLS, + Options: &option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + Detour: "detour", + }, + Handshake: option.ShadowTLSHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "google.com", + ServerPort: 443, + }, + }, + Version: 3, + Users: []option.ShadowTLSUser{ + {Password: password}, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "detour", + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: otherPort, + }, + Method: method, + Password: password, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "out", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestShadowTLSOutbound(t *testing.T) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startDockerContainer(t, DockerOptions{ + Image: ImageShadowTLS, + Ports: []uint16{serverPort, otherPort}, + EntryPoint: "shadow-tls", + Cmd: []string{"--v3", "--threads", "1", "server", "--listen", "0.0.0.0:" + F.ToString(serverPort), "--server", "127.0.0.1:" + F.ToString(otherPort), "--tls", "google.com:443", "--password", "hello"}, + Env: []string{"RUST_LOG=trace"}, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "detour", + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: otherPort, + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksOutboundOptions{ + Method: method, + Password: password, + DialerOptions: option.DialerOptions{ + Detour: "detour", + }, + }, + }, + { + Type: C.TypeShadowTLS, + Tag: "detour", + Options: &option.ShadowTLSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + }, + }, + Version: 3, + Password: "hello", + }, + }, + { + Type: C.TypeDirect, + Tag: "direct", + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"detour"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "direct", + }, + }, + }, + }, + }, + }, + }) + testTCP(t, clientPort, testPort) +} diff --git a/test/socks_test.go b/test/socks_test.go new file mode 100644 index 00000000..d33e349c --- /dev/null +++ b/test/socks_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "net" + "net/netip" + "testing" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + "github.com/stretchr/testify/require" +) + +func TestSOCKSUDPTimeout(t *testing.T) { + const testTimeout = 2 * time.Second + udpTimeout := option.UDPTimeoutCompat(testTimeout) + + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeSOCKS, + Tag: "socks-in", + Options: &option.SocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + UDPTimeout: udpTimeout, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + }) + + testUDPSessionIdleTimeout(t, clientPort, testPort, testTimeout) +} + +func TestMixedUDPTimeout(t *testing.T) { + const testTimeout = 2 * time.Second + udpTimeout := option.UDPTimeoutCompat(testTimeout) + + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + UDPTimeout: udpTimeout, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + }) + + testUDPSessionIdleTimeout(t, clientPort, testPort, testTimeout) +} + +func testUDPSessionIdleTimeout(t *testing.T, proxyPort uint16, echoPort uint16, expectedTimeout time.Duration) { + echoServer, err := listenPacket("udp", ":"+F.ToString(echoPort)) + require.NoError(t, err) + defer echoServer.Close() + + go func() { + buffer := make([]byte, 1024) + for { + n, address, err := echoServer.ReadFrom(buffer) + if err != nil { + return + } + _, _ = echoServer.WriteTo(buffer[:n], address) + } + }() + + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + + packetConn, err := dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", echoPort)) + require.NoError(t, err) + defer packetConn.Close() + + remoteAddress := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: int(echoPort)} + + _, err = packetConn.WriteTo([]byte("hello"), remoteAddress) + require.NoError(t, err) + + buffer := make([]byte, 1024) + packetConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, _, err := packetConn.ReadFrom(buffer) + require.NoError(t, err, "failed to receive echo response") + require.Equal(t, "hello", string(buffer[:n])) + t.Log("UDP echo successful, session established") + + packetConn.SetReadDeadline(time.Time{}) + + waitTime := expectedTimeout + time.Second + t.Logf("Waiting %v for UDP session to timeout...", waitTime) + time.Sleep(waitTime) + + _, err = packetConn.WriteTo([]byte("after-timeout"), remoteAddress) + if err != nil { + t.Logf("Write after timeout correctly failed: %v", err) + return + } + + packetConn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, _, err = packetConn.ReadFrom(buffer) + if err != nil { + t.Logf("Read after timeout correctly failed: %v", err) + return + } + + t.Fatalf("UDP session should have timed out after %v, but received response: %s", + expectedTimeout, string(buffer[:n])) +} diff --git a/test/ss_plugin_test.go b/test/ss_plugin_test.go new file mode 100644 index 00000000..f5d18639 --- /dev/null +++ b/test/ss_plugin_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestShadowsocksObfs(t *testing.T) { + for _, mode := range []string{ + "http", "tls", + } { + t.Run("obfs-local "+mode, func(t *testing.T) { + testShadowsocksPlugin(t, "obfs-local", "obfs="+mode, "--plugin obfs-server --plugin-opts obfs="+mode) + }) + } +} + +// Since I can't test this on m1 mac (rosetta error: bss_size overflow), I don't care about it +func _TestShadowsocksV2RayPlugin(t *testing.T) { + testShadowsocksPlugin(t, "v2ray-plugin", "", "--plugin v2ray-plugin --plugin-opts=server") +} + +func testShadowsocksPlugin(t *testing.T, name string, opts string, args string) { + startDockerContainer(t, DockerOptions{ + Image: ImageShadowsocksLegacy, + Ports: []uint16{serverPort, testPort}, + Env: []string{ + "SS_MODULE=ss-server", + "SS_CONFIG=-s 0.0.0.0 -u -p 10000 -m chacha20-ietf-poly1305 -k FzcLbKs2dY9mhL " + args, + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Method: "chacha20-ietf-poly1305", + Password: "FzcLbKs2dY9mhL", + Plugin: name, + PluginOptions: opts, + }, + }, + }, + }) + testSuitSimple(t, clientPort, testPort) +} diff --git a/test/tfo_test.go b/test/tfo_test.go new file mode 100644 index 00000000..d74dbfb6 --- /dev/null +++ b/test/tfo_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestTCPSlowOpen(t *testing.T) { + method := shadowaead.List[0] + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + TCPFastOpen: true, + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-out", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + DialerOptions: option.DialerOptions{ + TCPFastOpen: true, + }, + Method: method, + Password: password, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "ss-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/tls_test.go b/test/tls_test.go new file mode 100644 index 00000000..5aaf37c3 --- /dev/null +++ b/test/tls_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestUTLS(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: "password", + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "trojan-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + UTLS: &option.OutboundUTLSOptions{ + Enabled: true, + Fingerprint: "chrome", + }, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "trojan-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/trojan_test.go b/test/trojan_test.go new file mode 100644 index 00000000..cceed407 --- /dev/null +++ b/test/trojan_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" +) + +func TestTrojanOutbound(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startDockerContainer(t, DockerOptions{ + Image: ImageTrojan, + Ports: []uint16{serverPort, testPort}, + Bind: map[string]string{ + "trojan.json": "/config/config.json", + certPem: "/path/to/certificate.crt", + keyPem: "/path/to/private.key", + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeTrojan, + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestTrojanSelf(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: "password", + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "trojan-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "trojan-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestTrojanPlainSelf(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: "password", + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "trojan-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: "password", + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "trojan-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/tuic_test.go b/test/tuic_test.go new file mode 100644 index 00000000..d5f13bec --- /dev/null +++ b/test/tuic_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" +) + +func TestTUICSelf(t *testing.T) { + t.Run("self", func(t *testing.T) { + testTUICSelf(t, false, false) + }) + t.Run("self-udp-stream", func(t *testing.T) { + testTUICSelf(t, true, false) + }) + t.Run("self-early", func(t *testing.T) { + testTUICSelf(t, false, true) + }) +} + +func testTUICSelf(t *testing.T, udpStream bool, zeroRTTHandshake bool) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + var udpRelayMode string + if udpStream { + udpRelayMode = "quic" + } + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTUIC, + Options: &option.TUICInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TUICUser{{ + UUID: uuid.Nil.String(), + }}, + ZeroRTTHandshake: zeroRTTHandshake, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTUIC, + Tag: "tuic-out", + Options: &option.TUICOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: uuid.Nil.String(), + UDPRelayMode: udpRelayMode, + ZeroRTTHandshake: zeroRTTHandshake, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "tuic-out", + }, + }, + }, + }, + }, + }, + }) + testSuitLargeUDP(t, clientPort, testPort) +} + +func TestTUICInbound(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeTUIC, + Options: &option.TUICInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TUICUser{{ + UUID: "FE35D05B-8803-45C4-BAE6-723AD2CD5D3D", + Password: "tuic", + }}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + }) + startDockerContainer(t, DockerOptions{ + Image: ImageTUICClient, + Ports: []uint16{serverPort, clientPort}, + Bind: map[string]string{ + "tuic-client.json": "/etc/tuic/config.json", + caPem: "/etc/tuic/ca.pem", + }, + }) + testSuitLargeUDP(t, clientPort, testPort) +} + +func TestTUICOutbound(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startDockerContainer(t, DockerOptions{ + Image: ImageTUICServer, + Ports: []uint16{testPort}, + Bind: map[string]string{ + "tuic-server.json": "/etc/tuic/config.json", + certPem: "/etc/tuic/cert.pem", + keyPem: "/etc/tuic/key.pem", + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeTUIC, + Options: &option.TUICOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: "FE35D05B-8803-45C4-BAE6-723AD2CD5D3D", + Password: "tuic", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + testSuitLargeUDP(t, clientPort, testPort) +} diff --git a/test/v2ray_api_test.go b/test/v2ray_api_test.go new file mode 100644 index 00000000..22257032 --- /dev/null +++ b/test/v2ray_api_test.go @@ -0,0 +1,60 @@ +package main + +/* +import ( + "context" + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/v2rayapi" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/stretchr/testify/require" +) + +func TestV2RayAPI(t *testing.T) { + i := startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + Tag: "out", + }, + }, + Experimental: &option.ExperimentalOptions{ + V2RayAPI: &option.V2RayAPIOptions{ + Listen: "127.0.0.1:8080", + Stats: &option.V2RayStatsServiceOptions{ + Enabled: true, + Inbounds: []string{"in"}, + Outbounds: []string{"out"}, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) + statsService := i.Router().V2RayServer().StatsService() + require.NotNil(t, statsService) + response, err := statsService.(v2rayapi.StatsServiceServer).QueryStats(context.Background(), &v2rayapi.QueryStatsRequest{Regexp: true, Patterns: []string{".*"}}) + require.NoError(t, err) + count := response.Stat[0].Value + require.Equal(t, len(response.Stat), 4) + for _, stat := range response.Stat { + require.Equal(t, count, stat.Value) + } +} +*/ diff --git a/test/v2ray_grpc_test.go b/test/v2ray_grpc_test.go new file mode 100644 index 00000000..884cc42e --- /dev/null +++ b/test/v2ray_grpc_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "net/netip" + "os" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" + "github.com/spyzhov/ajson" + "github.com/stretchr/testify/require" +) + +func TestV2RayGRPCInbound(t *testing.T) { + t.Run("origin", func(t *testing.T) { + testV2RayGRPCInbound(t, false) + }) + t.Run("lite", func(t *testing.T) { + testV2RayGRPCInbound(t, true) + }) +} + +func testV2RayGRPCInbound(t *testing.T, forceLite bool) { + userId, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: userId.String(), + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + Transport: &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + ForceLite: forceLite, + }, + }, + }, + }, + }, + }) + content, err := os.ReadFile("config/vmess-grpc-client.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + config.MustKey("inbounds").MustIndex(0).MustKey("port").SetNumeric(float64(clientPort)) + outbound := config.MustKey("outbounds").MustIndex(0).MustKey("settings").MustKey("vnext").MustIndex(0) + outbound.MustKey("port").SetNumeric(float64(serverPort)) + user := outbound.MustKey("users").MustIndex(0) + user.MustKey("id").SetString(userId.String()) + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + Bind: map[string]string{ + certPem: "/path/to/certificate.crt", + keyPem: "/path/to/private.key", + }, + }) + + testSuitSimple(t, clientPort, testPort) +} + +func TestV2RayGRPCOutbound(t *testing.T) { + t.Run("origin", func(t *testing.T) { + testV2RayGRPCOutbound(t, false) + }) + t.Run("lite", func(t *testing.T) { + testV2RayGRPCOutbound(t, true) + }) +} + +func testV2RayGRPCOutbound(t *testing.T, forceLite bool) { + userId, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + + content, err := os.ReadFile("config/vmess-grpc-server.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + inbound := config.MustKey("inbounds").MustIndex(0) + inbound.MustKey("port").SetNumeric(float64(serverPort)) + inbound.MustKey("settings").MustKey("clients").MustIndex(0).MustKey("id").SetString(userId.String()) + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + Env: []string{"V2RAY_VMESS_AEAD_FORCED=false"}, + Bind: map[string]string{ + certPem: "/path/to/certificate.crt", + keyPem: "/path/to/private.key", + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: userId.String(), + Security: "zero", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + Transport: &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + ForceLite: forceLite, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestV2RayGRPCLite(t *testing.T) { + t.Run("server", func(t *testing.T) { + testV2RayTransportSelfWith(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + ForceLite: true, + }, + }, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + }, + }) + }) + t.Run("client", func(t *testing.T) { + testV2RayTransportSelfWith(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + }, + }, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + ForceLite: true, + }, + }) + }) + t.Run("self", func(t *testing.T) { + testV2RayTransportSelfWith(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + ForceLite: true, + }, + }, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: "TunService", + ForceLite: true, + }, + }) + }) +} diff --git a/test/v2ray_httpupgrade_test.go b/test/v2ray_httpupgrade_test.go new file mode 100644 index 00000000..9a79aa3a --- /dev/null +++ b/test/v2ray_httpupgrade_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func TestV2RayHTTPUpgrade(t *testing.T) { + t.Run("self", func(t *testing.T) { + testV2RayTransportSelf(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTPUpgrade, + }) + }) +} diff --git a/test/v2ray_transport_test.go b/test/v2ray_transport_test.go new file mode 100644 index 00000000..b32a5caf --- /dev/null +++ b/test/v2ray_transport_test.go @@ -0,0 +1,384 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" + "github.com/stretchr/testify/require" +) + +func TestV2RayHTTPSelf(t *testing.T) { + testV2RayTransportSelf(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Method: "POST", + }, + }) +} + +func TestV2RayHTTPPlainSelf(t *testing.T) { + testV2RayTransportNOTLSSelf(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + }) +} + +func testV2RayTransportSelf(t *testing.T, transport *option.V2RayTransportOptions) { + testV2RayTransportSelfWith(t, transport, transport) +} + +func testV2RayTransportSelfWith(t *testing.T, server, client *option.V2RayTransportOptions) { + t.Run("vmess", func(t *testing.T) { + testVMessTransportSelf(t, server, client) + }) + t.Run("trojan", func(t *testing.T) { + testTrojanTransportSelf(t, server, client) + }) +} + +func testVMessTransportSelf(t *testing.T, server *option.V2RayTransportOptions, client *option.V2RayTransportOptions) { + user, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: user.String(), + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + Transport: server, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + Security: "zero", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + Transport: client, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func testTrojanTransportSelf(t *testing.T, server *option.V2RayTransportOptions, client *option.V2RayTransportOptions) { + user, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrojan, + Options: &option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.TrojanUser{ + { + Name: "sekai", + Password: user.String(), + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + Transport: server, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrojan, + Tag: "vmess-out", + Options: &option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Password: user.String(), + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + Transport: client, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func TestVMessQUICSelf(t *testing.T) { + transport := &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeQUIC, + } + user, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: user.String(), + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + Transport: transport, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + Security: "zero", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + Transport: transport, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuitSimple1(t, clientPort, testPort) +} + +func testV2RayTransportNOTLSSelf(t *testing.T, transport *option.V2RayTransportOptions) { + user, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: user.String(), + }, + }, + Transport: transport, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: user.String(), + Security: "zero", + Transport: transport, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/v2ray_ws_test.go b/test/v2ray_ws_test.go new file mode 100644 index 00000000..4db4b372 --- /dev/null +++ b/test/v2ray_ws_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "net/netip" + "os" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" + "github.com/spyzhov/ajson" + "github.com/stretchr/testify/require" +) + +func TestV2RayWebsocket(t *testing.T) { + t.Run("self", func(t *testing.T) { + testV2RayTransportSelf(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + }) + }) + t.Run("self-early-data", func(t *testing.T) { + testV2RayTransportSelf(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + MaxEarlyData: 2048, + }, + }) + }) + t.Run("self-xray-early-data", func(t *testing.T) { + testV2RayTransportSelf(t, &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + MaxEarlyData: 2048, + EarlyDataHeaderName: "Sec-WebSocket-Protocol", + }, + }) + }) + t.Run("inbound", func(t *testing.T) { + testV2RayWebsocketInbound(t, 0, "") + }) + t.Run("inbound-early-data", func(t *testing.T) { + testV2RayWebsocketInbound(t, 2048, "") + }) + t.Run("inbound-xray-early-data", func(t *testing.T) { + testV2RayWebsocketInbound(t, 2048, "Sec-WebSocket-Protocol") + }) + t.Run("outbound", func(t *testing.T) { + testV2RayWebsocketOutbound(t, 0, "") + }) + t.Run("outbound-early-data", func(t *testing.T) { + testV2RayWebsocketOutbound(t, 2048, "") + }) + t.Run("outbound-xray-early-data", func(t *testing.T) { + testV2RayWebsocketOutbound(t, 2048, "Sec-WebSocket-Protocol") + }) +} + +func testV2RayWebsocketInbound(t *testing.T, maxEarlyData uint32, earlyDataHeaderName string) { + userId, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: userId.String(), + }, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + Transport: &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + MaxEarlyData: maxEarlyData, + EarlyDataHeaderName: earlyDataHeaderName, + }, + }, + }, + }, + }, + }) + content, err := os.ReadFile("config/vmess-ws-client.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + config.MustKey("inbounds").MustIndex(0).MustKey("port").SetNumeric(float64(clientPort)) + outbound := config.MustKey("outbounds").MustIndex(0) + settings := outbound.MustKey("settings").MustKey("vnext").MustIndex(0) + settings.MustKey("port").SetNumeric(float64(serverPort)) + user := settings.MustKey("users").MustIndex(0) + user.MustKey("id").SetString(userId.String()) + wsSettings := outbound.MustKey("streamSettings").MustKey("wsSettings") + wsSettings.MustKey("maxEarlyData").SetNumeric(float64(maxEarlyData)) + wsSettings.MustKey("earlyDataHeaderName").SetString(earlyDataHeaderName) + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + Bind: map[string]string{ + certPem: "/path/to/certificate.crt", + keyPem: "/path/to/private.key", + }, + }) + + testSuitSimple(t, clientPort, testPort) +} + +func testV2RayWebsocketOutbound(t *testing.T, maxEarlyData uint32, earlyDataHeaderName string) { + userId, err := uuid.DefaultGenerator.NewV4() + require.NoError(t, err) + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + + content, err := os.ReadFile("config/vmess-ws-server.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + inbound := config.MustKey("inbounds").MustIndex(0) + inbound.MustKey("port").SetNumeric(float64(serverPort)) + inbound.MustKey("settings").MustKey("clients").MustIndex(0).MustKey("id").SetString(userId.String()) + wsSettings := inbound.MustKey("streamSettings").MustKey("wsSettings") + wsSettings.MustKey("maxEarlyData").SetNumeric(float64(maxEarlyData)) + wsSettings.MustKey("earlyDataHeaderName").SetString(earlyDataHeaderName) + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + Env: []string{"V2RAY_VMESS_AEAD_FORCED=false"}, + Bind: map[string]string{ + certPem: "/path/to/certificate.crt", + keyPem: "/path/to/private.key", + }, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + UUID: userId.String(), + Security: "zero", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + Transport: &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + MaxEarlyData: maxEarlyData, + EarlyDataHeaderName: earlyDataHeaderName, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/vmess_test.go b/test/vmess_test.go new file mode 100644 index 00000000..4da76c6e --- /dev/null +++ b/test/vmess_test.go @@ -0,0 +1,337 @@ +package main + +import ( + "net/netip" + "os" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/gofrs/uuid/v5" + "github.com/spyzhov/ajson" + "github.com/stretchr/testify/require" +) + +func newUUID() uuid.UUID { + user, _ := uuid.DefaultGenerator.NewV4() + return user +} + +func TestVMessAuto(t *testing.T) { + security := "auto" + t.Run("self", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, false) + }) + t.Run("packetaddr", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, true) + }) + t.Run("inbound", func(t *testing.T) { + testVMessInboundWithV2Ray(t, security, 0, false) + }) + t.Run("outbound", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 0) + }) +} + +func TestVMess(t *testing.T) { + for _, security := range []string{ + "zero", + } { + t.Run(security, func(t *testing.T) { + testVMess0(t, security) + }) + } + for _, security := range []string{ + "none", + } { + t.Run(security, func(t *testing.T) { + testVMess1(t, security) + }) + } + for _, security := range []string{ + "aes-128-gcm", "chacha20-poly1305", "aes-128-cfb", + } { + t.Run(security, func(t *testing.T) { + testVMess2(t, security) + }) + } +} + +func testVMess0(t *testing.T, security string) { + t.Run("self", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, false) + }) + t.Run("self-legacy", func(t *testing.T) { + testVMessSelf(t, security, 1, false, false, false) + }) + t.Run("packetaddr", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, true) + }) + t.Run("outbound", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 0) + }) + t.Run("outbound-legacy", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 1) + }) +} + +func testVMess1(t *testing.T, security string) { + t.Run("self", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, false) + }) + t.Run("self-legacy", func(t *testing.T) { + testVMessSelf(t, security, 1, false, false, false) + }) + t.Run("packetaddr", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, true) + }) + t.Run("inbound", func(t *testing.T) { + testVMessInboundWithV2Ray(t, security, 0, false) + }) + t.Run("outbound", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 0) + }) + t.Run("outbound-legacy", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 1) + }) +} + +func testVMess2(t *testing.T, security string) { + t.Run("self", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, false) + }) + t.Run("self-padding", func(t *testing.T) { + testVMessSelf(t, security, 0, true, false, false) + }) + t.Run("self-authid", func(t *testing.T) { + testVMessSelf(t, security, 0, false, true, false) + }) + t.Run("self-padding-authid", func(t *testing.T) { + testVMessSelf(t, security, 0, true, true, false) + }) + t.Run("self-legacy", func(t *testing.T) { + testVMessSelf(t, security, 1, false, false, false) + }) + t.Run("self-legacy-padding", func(t *testing.T) { + testVMessSelf(t, security, 1, true, false, false) + }) + t.Run("packetaddr", func(t *testing.T) { + testVMessSelf(t, security, 0, false, false, true) + }) + t.Run("inbound", func(t *testing.T) { + testVMessInboundWithV2Ray(t, security, 0, false) + }) + t.Run("inbound-authid", func(t *testing.T) { + testVMessInboundWithV2Ray(t, security, 0, true) + }) + t.Run("inbound-legacy", func(t *testing.T) { + testVMessInboundWithV2Ray(t, security, 64, false) + }) + t.Run("outbound", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 0) + }) + t.Run("outbound-padding", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, true, false, 0) + }) + t.Run("outbound-authid", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, true, 0) + }) + t.Run("outbound-padding-authid", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, true, true, 0) + }) + t.Run("outbound-legacy", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, false, false, 1) + }) + t.Run("outbound-legacy-padding", func(t *testing.T) { + testVMessOutboundWithV2Ray(t, security, true, false, 1) + }) +} + +func testVMessInboundWithV2Ray(t *testing.T, security string, alterId int, authenticatedLength bool) { + userId := newUUID() + content, err := os.ReadFile("config/vmess-client.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + config.MustKey("inbounds").MustIndex(0).MustKey("port").SetNumeric(float64(clientPort)) + outbound := config.MustKey("outbounds").MustIndex(0).MustKey("settings").MustKey("vnext").MustIndex(0) + outbound.MustKey("port").SetNumeric(float64(serverPort)) + user := outbound.MustKey("users").MustIndex(0) + user.MustKey("id").SetString(userId.String()) + user.MustKey("alterId").SetNumeric(float64(alterId)) + user.MustKey("security").SetString(security) + var experiments string + if authenticatedLength { + experiments += "AuthenticatedLength" + } + user.MustKey("experiments").SetString(experiments) + + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + Env: []string{"V2RAY_VMESS_AEAD_FORCED=false"}, + }) + + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: userId.String(), + AlterId: alterId, + }, + }, + }, + }, + }, + }) + + testSuitSimple(t, clientPort, testPort) +} + +func testVMessOutboundWithV2Ray(t *testing.T, security string, globalPadding bool, authenticatedLength bool, alterId int) { + user := newUUID() + content, err := os.ReadFile("config/vmess-server.json") + require.NoError(t, err) + config, err := ajson.Unmarshal(content) + require.NoError(t, err) + + inbound := config.MustKey("inbounds").MustIndex(0) + inbound.MustKey("port").SetNumeric(float64(serverPort)) + inbound.MustKey("settings").MustKey("clients").MustIndex(0).MustKey("id").SetString(user.String()) + inbound.MustKey("settings").MustKey("clients").MustIndex(0).MustKey("alterId").SetNumeric(float64(alterId)) + + content, err = ajson.Marshal(config) + require.NoError(t, err) + + startDockerContainer(t, DockerOptions{ + Image: ImageV2RayCore, + Ports: []uint16{serverPort, testPort}, + EntryPoint: "v2ray", + Cmd: []string{"run"}, + Stdin: content, + Env: []string{"V2RAY_VMESS_AEAD_FORCED=false"}, + }) + + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeVMess, + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Security: security, + UUID: user.String(), + GlobalPadding: globalPadding, + AuthenticatedLength: authenticatedLength, + AlterId: alterId, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} + +func testVMessSelf(t *testing.T, security string, alterId int, globalPadding bool, authenticatedLength bool, packetAddr bool) { + user := newUUID() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeVMess, + Options: &option.VMessInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []option.VMessUser{ + { + Name: "sekai", + UUID: user.String(), + AlterId: alterId, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeVMess, + Tag: "vmess-out", + Options: &option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Security: security, + UUID: user.String(), + AlterId: alterId, + GlobalPadding: globalPadding, + AuthenticatedLength: authenticatedLength, + PacketEncoding: "packetaddr", + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "vmess-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/wrapper_test.go b/test/wrapper_test.go new file mode 100644 index 00000000..9eb9ed78 --- /dev/null +++ b/test/wrapper_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + + "github.com/stretchr/testify/require" +) + +func TestOptionsWrapper(t *testing.T) { + inbound := option.Inbound{ + Type: C.TypeHTTP, + Options: &option.HTTPMixedInboundOptions{ + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + }, + }, + }, + } + tlsOptionsWrapper, loaded := inbound.Options.(option.InboundTLSOptionsWrapper) + require.True(t, loaded, "find inbound tls options") + tlsOptions := tlsOptionsWrapper.TakeInboundTLSOptions() + require.NotNil(t, tlsOptions, "find inbound tls options") + tlsOptions.Enabled = false + tlsOptionsWrapper.ReplaceInboundTLSOptions(tlsOptions) + require.False(t, inbound.Options.(*option.HTTPMixedInboundOptions).TLS.Enabled, "replace tls enabled") +} diff --git a/transport/simple-obfs/README.md b/transport/simple-obfs/README.md new file mode 100644 index 00000000..d438d0f9 --- /dev/null +++ b/transport/simple-obfs/README.md @@ -0,0 +1,4 @@ +# simple-obfs + +mod from https://github.com/Dreamacro/clash/transport/simple-obfs +version: 1.11.8 \ No newline at end of file diff --git a/transport/simple-obfs/http.go b/transport/simple-obfs/http.go new file mode 100644 index 00000000..df38768e --- /dev/null +++ b/transport/simple-obfs/http.go @@ -0,0 +1,98 @@ +package obfs + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "math/rand" + "net" + "net/http" + + B "github.com/sagernet/sing/common/buf" +) + +// HTTPObfs is shadowsocks http simple-obfs implementation +type HTTPObfs struct { + net.Conn + host string + port string + buf []byte + offset int + firstRequest bool + firstResponse bool +} + +func (ho *HTTPObfs) Read(b []byte) (int, error) { + if ho.buf != nil { + n := copy(b, ho.buf[ho.offset:]) + ho.offset += n + if ho.offset == len(ho.buf) { + B.Put(ho.buf) + ho.buf = nil + } + return n, nil + } + + if ho.firstResponse { + buf := B.Get(B.BufferSize) + n, err := ho.Conn.Read(buf) + if err != nil { + B.Put(buf) + return 0, err + } + idx := bytes.Index(buf[:n], []byte("\r\n\r\n")) + if idx == -1 { + B.Put(buf) + return 0, io.EOF + } + ho.firstResponse = false + length := n - (idx + 4) + n = copy(b, buf[idx+4:n]) + if length > n { + ho.buf = buf[:idx+4+length] + ho.offset = idx + 4 + n + } else { + B.Put(buf) + } + return n, nil + } + return ho.Conn.Read(b) +} + +func (ho *HTTPObfs) Write(b []byte) (int, error) { + if ho.firstRequest { + randBytes := make([]byte, 16) + rand.Read(randBytes) + req, _ := http.NewRequest("GET", fmt.Sprintf("http://%s/", ho.host), bytes.NewBuffer(b[:])) + req.Header.Set("User-Agent", fmt.Sprintf("curl/7.%d.%d", rand.Int()%54, rand.Int()%2)) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Host = ho.host + if ho.port != "80" { + req.Host = fmt.Sprintf("%s:%s", ho.host, ho.port) + } + req.Header.Set("Sec-WebSocket-Key", base64.URLEncoding.EncodeToString(randBytes)) + req.ContentLength = int64(len(b)) + err := req.Write(ho.Conn) + ho.firstRequest = false + return len(b), err + } + + return ho.Conn.Write(b) +} + +func (ho *HTTPObfs) Upstream() any { + return ho.Conn +} + +// NewHTTPObfs return a HTTPObfs +func NewHTTPObfs(conn net.Conn, host string, port string) net.Conn { + return &HTTPObfs{ + Conn: conn, + firstRequest: true, + firstResponse: true, + host: host, + port: port, + } +} diff --git a/transport/simple-obfs/tls.go b/transport/simple-obfs/tls.go new file mode 100644 index 00000000..96564815 --- /dev/null +++ b/transport/simple-obfs/tls.go @@ -0,0 +1,205 @@ +package obfs + +import ( + "bytes" + "encoding/binary" + "io" + "math/rand" + "net" + "time" + + B "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/random" +) + +func init() { + random.InitializeSeed() +} + +const ( + chunkSize = 1 << 14 // 2 ** 14 == 16 * 1024 +) + +// TLSObfs is shadowsocks tls simple-obfs implementation +type TLSObfs struct { + net.Conn + server string + remain int + firstRequest bool + firstResponse bool +} + +func (to *TLSObfs) read(b []byte, discardN int) (int, error) { + buf := B.Get(discardN) + _, err := io.ReadFull(to.Conn, buf) + B.Put(buf) + if err != nil { + return 0, err + } + + sizeBuf := make([]byte, 2) + _, err = io.ReadFull(to.Conn, sizeBuf) + if err != nil { + return 0, nil + } + + length := int(binary.BigEndian.Uint16(sizeBuf)) + if length > len(b) { + n, err := to.Conn.Read(b) + if err != nil { + return n, err + } + to.remain = length - n + return n, nil + } + + return io.ReadFull(to.Conn, b[:length]) +} + +func (to *TLSObfs) Read(b []byte) (int, error) { + if to.remain > 0 { + length := to.remain + if length > len(b) { + length = len(b) + } + + n, err := io.ReadFull(to.Conn, b[:length]) + to.remain -= n + return n, err + } + + if to.firstResponse { + // type + ver + lensize + 91 = 96 + // type + ver + lensize + 1 = 6 + // type + ver = 3 + to.firstResponse = false + return to.read(b, 105) + } + + // type + ver = 3 + return to.read(b, 3) +} + +func (to *TLSObfs) Write(b []byte) (int, error) { + length := len(b) + for i := 0; i < length; i += chunkSize { + end := i + chunkSize + if end > length { + end = length + } + + n, err := to.write(b[i:end]) + if err != nil { + return n, err + } + } + return length, nil +} + +func (to *TLSObfs) write(b []byte) (int, error) { + if to.firstRequest { + helloMsg := makeClientHelloMsg(b, to.server) + _, err := to.Conn.Write(helloMsg) + to.firstRequest = false + return len(b), err + } + + buf := B.NewSize(5 + len(b)) + defer buf.Release() + buf.Write([]byte{0x17, 0x03, 0x03}) + binary.Write(buf, binary.BigEndian, uint16(len(b))) + buf.Write(b) + _, err := to.Conn.Write(buf.Bytes()) + return len(b), err +} + +func (to *TLSObfs) Upstream() any { + return to.Conn +} + +// NewTLSObfs return a SimpleObfs +func NewTLSObfs(conn net.Conn, server string) net.Conn { + return &TLSObfs{ + Conn: conn, + server: server, + firstRequest: true, + firstResponse: true, + } +} + +func makeClientHelloMsg(data []byte, server string) []byte { + random := make([]byte, 28) + sessionID := make([]byte, 32) + rand.Read(random) + rand.Read(sessionID) + + buf := &bytes.Buffer{} + + // handshake, TLS 1.0 version, length + buf.WriteByte(22) + buf.Write([]byte{0x03, 0x01}) + length := uint16(212 + len(data) + len(server)) + buf.WriteByte(byte(length >> 8)) + buf.WriteByte(byte(length & 0xff)) + + // clientHello, length, TLS 1.2 version + buf.WriteByte(1) + buf.WriteByte(0) + binary.Write(buf, binary.BigEndian, uint16(208+len(data)+len(server))) + buf.Write([]byte{0x03, 0x03}) + + // random with timestamp, sid len, sid + binary.Write(buf, binary.BigEndian, uint32(time.Now().Unix())) + buf.Write(random) + buf.WriteByte(32) + buf.Write(sessionID) + + // cipher suites + buf.Write([]byte{0x00, 0x38}) + buf.Write([]byte{ + 0xc0, 0x2c, 0xc0, 0x30, 0x00, 0x9f, 0xcc, 0xa9, 0xcc, 0xa8, 0xcc, 0xaa, 0xc0, 0x2b, 0xc0, 0x2f, + 0x00, 0x9e, 0xc0, 0x24, 0xc0, 0x28, 0x00, 0x6b, 0xc0, 0x23, 0xc0, 0x27, 0x00, 0x67, 0xc0, 0x0a, + 0xc0, 0x14, 0x00, 0x39, 0xc0, 0x09, 0xc0, 0x13, 0x00, 0x33, 0x00, 0x9d, 0x00, 0x9c, 0x00, 0x3d, + 0x00, 0x3c, 0x00, 0x35, 0x00, 0x2f, 0x00, 0xff, + }) + + // compression + buf.Write([]byte{0x01, 0x00}) + + // extension length + binary.Write(buf, binary.BigEndian, uint16(79+len(data)+len(server))) + + // session ticket + buf.Write([]byte{0x00, 0x23}) + binary.Write(buf, binary.BigEndian, uint16(len(data))) + buf.Write(data) + + // server name + buf.Write([]byte{0x00, 0x00}) + binary.Write(buf, binary.BigEndian, uint16(len(server)+5)) + binary.Write(buf, binary.BigEndian, uint16(len(server)+3)) + buf.WriteByte(0) + binary.Write(buf, binary.BigEndian, uint16(len(server))) + buf.Write([]byte(server)) + + // ec_point + buf.Write([]byte{0x00, 0x0b, 0x00, 0x04, 0x03, 0x01, 0x00, 0x02}) + + // groups + buf.Write([]byte{0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x19, 0x00, 0x18}) + + // signature + buf.Write([]byte{ + 0x00, 0x0d, 0x00, 0x20, 0x00, 0x1e, 0x06, 0x01, 0x06, 0x02, 0x06, 0x03, 0x05, + 0x01, 0x05, 0x02, 0x05, 0x03, 0x04, 0x01, 0x04, 0x02, 0x04, 0x03, 0x03, 0x01, + 0x03, 0x02, 0x03, 0x03, 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, + }) + + // encrypt then mac + buf.Write([]byte{0x00, 0x16, 0x00, 0x00}) + + // extended master secret + buf.Write([]byte{0x00, 0x17, 0x00, 0x00}) + + return buf.Bytes() +} diff --git a/transport/sip003/args.go b/transport/sip003/args.go new file mode 100644 index 00000000..b9fae3da --- /dev/null +++ b/transport/sip003/args.go @@ -0,0 +1,119 @@ +package sip003 + +import ( + "bytes" + "fmt" +) + +// mod from https://github.com/shadowsocks/v2ray-plugin/blob/master/args.go + +// Args maps a string key to a list of values. It is similar to url.Values. +type Args map[string][]string + +// Get the first value associated with the given key. If there are any values +// associated with the key, the value return has the value and ok is set to +// true. If there are no values for the given key, value is "" and ok is false. +// If you need access to multiple values, use the map directly. +func (args Args) Get(key string) (value string, ok bool) { + if args == nil { + return "", false + } + vals, ok := args[key] + if !ok || len(vals) == 0 { + return "", false + } + return vals[0], true +} + +// Add Append value to the list of values for key. +func (args Args) Add(key, value string) { + args[key] = append(args[key], value) +} + +// Return the index of the next unescaped byte in s that is in the term set, or +// else the length of the string if no terminators appear. Additionally return +// the unescaped string up to the returned index. +func indexUnescaped(s string, term []byte) (int, string, error) { + var i int + unesc := make([]byte, 0) + for i = 0; i < len(s); i++ { + b := s[i] + // A terminator byte? + if bytes.IndexByte(term, b) != -1 { + break + } + if b == '\\' { + i++ + if i >= len(s) { + return 0, "", fmt.Errorf("nothing following final escape in %q", s) + } + b = s[i] + } + unesc = append(unesc, b) + } + return i, string(unesc), nil +} + +// ParsePluginOptions Parse a name–value mapping as from SS_PLUGIN_OPTIONS. +// +// " is a k=v string value with options that are to be passed to the +// transport. semicolons, equal signs and backslashes must be escaped +// with a backslash." +// Example: secret=nou;cache=/tmp/cache;secret=yes +func ParsePluginOptions(s string) (opts Args, err error) { + opts = make(Args) + if len(s) == 0 { + return + } + i := 0 + for { + var key, value string + var offset, begin int + + if i >= len(s) { + break + } + begin = i + // Read the key. + offset, key, err = indexUnescaped(s[i:], []byte{'=', ';'}) + if err != nil { + return + } + if len(key) == 0 { + err = fmt.Errorf("empty key in %q", s[begin:i]) + return + } + i += offset + // End of string or no equals sign? + if i >= len(s) || s[i] != '=' { + opts.Add(key, "1") + // Skip the semicolon. + i++ + continue + } + // Skip the equals sign. + i++ + // Read the value. + offset, value, err = indexUnescaped(s[i:], []byte{';'}) + if err != nil { + return + } + i += offset + opts.Add(key, value) + // Skip the semicolon. + i++ + } + return opts, nil +} + +// Escape backslashes and all the bytes that are in set. +func backslashEscape(s string, set []byte) string { + var buf bytes.Buffer + for _, b := range []byte(s) { + if b == '\\' || bytes.IndexByte(set, b) != -1 { + buf.WriteByte('\\') + } + buf.WriteByte(b) + } + return buf.String() +} diff --git a/transport/sip003/obfs.go b/transport/sip003/obfs.go new file mode 100644 index 00000000..129e7756 --- /dev/null +++ b/transport/sip003/obfs.go @@ -0,0 +1,62 @@ +package sip003 + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/transport/simple-obfs" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ Plugin = (*ObfsLocal)(nil) + +func init() { + RegisterPlugin("obfs-local", newObfsLocal) +} + +func newObfsLocal(ctx context.Context, pluginOpts Args, router adapter.Router, dialer N.Dialer, serverAddr M.Socksaddr) (Plugin, error) { + plugin := &ObfsLocal{ + dialer: dialer, + serverAddr: serverAddr, + } + mode := "http" + if obfsMode, loaded := pluginOpts.Get("obfs"); loaded { + mode = obfsMode + } + if obfsHost, loaded := pluginOpts.Get("obfs-host"); loaded { + plugin.host = obfsHost + } + switch mode { + case "http": + case "tls": + plugin.tls = true + default: + return nil, E.New("unknown obfs mode ", mode) + } + plugin.port = F.ToString(serverAddr.Port) + return plugin, nil +} + +type ObfsLocal struct { + dialer N.Dialer + serverAddr M.Socksaddr + tls bool + host string + port string +} + +func (o *ObfsLocal) DialContext(ctx context.Context) (net.Conn, error) { + conn, err := o.dialer.DialContext(ctx, N.NetworkTCP, o.serverAddr) + if err != nil { + return nil, err + } + if !o.tls { + return obfs.NewHTTPObfs(conn, o.host, o.port), nil + } else { + return obfs.NewTLSObfs(conn, o.host), nil + } +} diff --git a/transport/sip003/plugin.go b/transport/sip003/plugin.go new file mode 100644 index 00000000..546a3f36 --- /dev/null +++ b/transport/sip003/plugin.go @@ -0,0 +1,38 @@ +package sip003 + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type PluginConstructor func(ctx context.Context, pluginArgs Args, router adapter.Router, dialer N.Dialer, serverAddr M.Socksaddr) (Plugin, error) + +type Plugin interface { + DialContext(ctx context.Context) (net.Conn, error) +} + +var plugins map[string]PluginConstructor + +func RegisterPlugin(name string, constructor PluginConstructor) { + if plugins == nil { + plugins = make(map[string]PluginConstructor) + } + plugins[name] = constructor +} + +func CreatePlugin(ctx context.Context, name string, pluginArgs string, router adapter.Router, dialer N.Dialer, serverAddr M.Socksaddr) (Plugin, error) { + pluginOptions, err := ParsePluginOptions(pluginArgs) + if err != nil { + return nil, E.Cause(err, "parse plugin_opts") + } + constructor, loaded := plugins[name] + if !loaded { + return nil, E.New("plugin not found: ", name) + } + return constructor(ctx, pluginOptions, router, dialer, serverAddr) +} diff --git a/transport/sip003/v2ray.go b/transport/sip003/v2ray.go new file mode 100644 index 00000000..f35e2654 --- /dev/null +++ b/transport/sip003/v2ray.go @@ -0,0 +1,119 @@ +package sip003 + +import ( + "context" + "net" + "strconv" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2ray" + "github.com/sagernet/sing-vmess" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func init() { + RegisterPlugin("v2ray-plugin", newV2RayPlugin) +} + +func newV2RayPlugin(ctx context.Context, pluginOpts Args, router adapter.Router, dialer N.Dialer, serverAddr M.Socksaddr) (Plugin, error) { + var tlsOptions option.OutboundTLSOptions + if _, loaded := pluginOpts.Get("tls"); loaded { + tlsOptions.Enabled = true + } + if certPath, certLoaded := pluginOpts.Get("cert"); certLoaded { + tlsOptions.CertificatePath = certPath + } + if certRaw, certLoaded := pluginOpts.Get("certRaw"); certLoaded { + certHead := "-----BEGIN CERTIFICATE-----" + certTail := "-----END CERTIFICATE-----" + fixedCert := certHead + "\n" + certRaw + "\n" + certTail + tlsOptions.Certificate = []string{fixedCert} + } + + mode := "websocket" + if modeOpt, loaded := pluginOpts.Get("mode"); loaded { + mode = modeOpt + } + + host := "cloudfront.com" + path := "/" + + if hostOpt, loaded := pluginOpts.Get("host"); loaded { + host = hostOpt + tlsOptions.ServerName = hostOpt + } + if pathOpt, loaded := pluginOpts.Get("path"); loaded { + path = pathOpt + } + + var tlsClient tls.Config + var err error + if tlsOptions.Enabled { + tlsClient, err = tls.NewClient(ctx, logger.NOP(), serverAddr.AddrString(), tlsOptions) + if err != nil { + return nil, err + } + } + + var mux int + var transportOptions option.V2RayTransportOptions + switch mode { + case "websocket": + transportOptions = option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: map[string]badoption.Listable[string]{ + "Host": []string{host}, + }, + Path: path, + }, + } + if muxOpt, loaded := pluginOpts.Get("mux"); loaded { + muxVal, err := strconv.Atoi(muxOpt) + if err != nil { + return nil, E.Cause(err, "parse mux value") + } + mux = muxVal + } else { + mux = 1 + } + case "quic": + transportOptions = option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeQUIC, + } + default: + return nil, E.New("v2ray-plugin: unknown mode: " + mode) + } + + transport, err := v2ray.NewClientTransport(context.Background(), dialer, serverAddr, transportOptions, tlsClient) + if err != nil { + return nil, err + } + + if mux > 0 { + return &v2rayMuxWrapper{transport}, nil + } + + return transport, nil +} + +var _ Plugin = (*v2rayMuxWrapper)(nil) + +type v2rayMuxWrapper struct { + adapter.V2RayClientTransport +} + +func (w *v2rayMuxWrapper) DialContext(ctx context.Context) (net.Conn, error) { + conn, err := w.V2RayClientTransport.DialContext(ctx) + if err != nil { + return nil, err + } + return vmess.NewMuxConnWrapper(conn, vmess.MuxDestination), nil +} diff --git a/transport/trojan/mux.go b/transport/trojan/mux.go new file mode 100644 index 00000000..72d5a776 --- /dev/null +++ b/transport/trojan/mux.go @@ -0,0 +1,84 @@ +package trojan + +import ( + std_bufio "bufio" + "context" + "net" + "os" + + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/task" + "github.com/sagernet/smux" +) + +func HandleMuxConnection(ctx context.Context, conn net.Conn, source M.Socksaddr, handler Handler, logger logger.ContextLogger, onClose N.CloseHandlerFunc) error { + session, err := smux.Server(conn, smuxConfig()) + if err != nil { + return err + } + var group task.Group + group.Append0(func(_ context.Context) error { + var stream net.Conn + for { + stream, err = session.AcceptStream() + if err != nil { + return err + } + go newMuxConnection(ctx, stream, source, handler, logger) + } + }) + group.Cleanup(func() { + session.Close() + if onClose != nil { + onClose(os.ErrClosed) + } + }) + return group.Run(ctx) +} + +func newMuxConnection(ctx context.Context, conn net.Conn, source M.Socksaddr, handler Handler, logger logger.ContextLogger) { + err := newMuxConnection0(ctx, conn, source, handler) + if err != nil { + logger.ErrorContext(ctx, E.Cause(err, "process trojan-go multiplex connection")) + } +} + +func newMuxConnection0(ctx context.Context, conn net.Conn, source M.Socksaddr, handler Handler) error { + reader := std_bufio.NewReader(conn) + command, err := reader.ReadByte() + if err != nil { + return E.Cause(err, "read command") + } + destination, err := M.SocksaddrSerializer.ReadAddrPort(reader) + if err != nil { + return E.Cause(err, "read destination") + } + if reader.Buffered() > 0 { + buffer := buf.NewSize(reader.Buffered()) + _, err = buffer.ReadFullFrom(reader, buffer.Len()) + if err != nil { + return err + } + conn = bufio.NewCachedConn(conn, buffer) + } + switch command { + case CommandTCP: + handler.NewConnectionEx(ctx, conn, source, destination, nil) + case CommandUDP: + handler.NewPacketConnectionEx(ctx, &PacketConn{Conn: conn}, source, destination, nil) + default: + return E.New("unknown command ", command) + } + return nil +} + +func smuxConfig() *smux.Config { + config := smux.DefaultConfig() + config.KeepAliveDisabled = true + return config +} diff --git a/transport/trojan/protocol.go b/transport/trojan/protocol.go new file mode 100644 index 00000000..0456b6b9 --- /dev/null +++ b/transport/trojan/protocol.go @@ -0,0 +1,321 @@ +package trojan + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "net" + "os" + "sync" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" +) + +const ( + KeyLength = 56 + CommandTCP = 1 + CommandUDP = 3 + CommandMux = 0x7f +) + +var CRLF = []byte{'\r', '\n'} + +var _ N.EarlyWriter = (*ClientConn)(nil) + +type ClientConn struct { + N.ExtendedConn + key [KeyLength]byte + destination M.Socksaddr + headerWritten bool +} + +func NewClientConn(conn net.Conn, key [KeyLength]byte, destination M.Socksaddr) *ClientConn { + return &ClientConn{ + ExtendedConn: bufio.NewExtendedConn(conn), + key: key, + destination: destination, + } +} + +func (c *ClientConn) NeedHandshakeForWrite() bool { + return !c.headerWritten +} + +func (c *ClientConn) Write(p []byte) (n int, err error) { + if c.headerWritten { + return c.ExtendedConn.Write(p) + } + err = ClientHandshake(c.ExtendedConn, c.key, c.destination, p) + if err != nil { + return + } + n = len(p) + c.headerWritten = true + return +} + +func (c *ClientConn) WriteBuffer(buffer *buf.Buffer) error { + if c.headerWritten { + return c.ExtendedConn.WriteBuffer(buffer) + } + err := ClientHandshakeBuffer(c.ExtendedConn, c.key, c.destination, buffer) + if err != nil { + return err + } + c.headerWritten = true + return nil +} + +func (c *ClientConn) FrontHeadroom() int { + if !c.headerWritten { + return KeyLength + 5 + M.MaxSocksaddrLength + } + return 0 +} + +func (c *ClientConn) Upstream() any { + return c.ExtendedConn +} + +func (c *ClientConn) ReaderReplaceable() bool { + return c.headerWritten +} + +func (c *ClientConn) WriterReplaceable() bool { + return c.headerWritten +} + +type ClientPacketConn struct { + net.Conn + access sync.Mutex + key [KeyLength]byte + headerWritten bool + readWaitOptions N.ReadWaitOptions +} + +func NewClientPacketConn(conn net.Conn, key [KeyLength]byte) *ClientPacketConn { + return &ClientPacketConn{ + Conn: conn, + key: key, + } +} + +func (c *ClientPacketConn) NeedHandshake() bool { + return !c.headerWritten +} + +func (c *ClientPacketConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { + return ReadPacket(c.Conn, buffer) +} + +func (c *ClientPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + if !c.headerWritten { + c.access.Lock() + if c.headerWritten { + c.access.Unlock() + } else { + err := ClientHandshakePacket(c.Conn, c.key, destination, buffer) + c.headerWritten = true + c.access.Unlock() + return err + } + } + return WritePacket(c.Conn, buffer, destination) +} + +func (c *ClientPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + buffer := buf.With(p) + destination, err := c.ReadPacket(buffer) + if err != nil { + return + } + n = buffer.Len() + if destination.IsDomain() { + addr = destination + } else { + addr = destination.UDPAddr() + } + return +} + +func (c *ClientPacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + return bufio.WritePacket(c, p, addr) +} + +func (c *ClientPacketConn) Read(p []byte) (n int, err error) { + n, _, err = c.ReadFrom(p) + return +} + +func (c *ClientPacketConn) Write(p []byte) (n int, err error) { + return 0, os.ErrInvalid +} + +func (c *ClientPacketConn) FrontHeadroom() int { + if !c.headerWritten { + return KeyLength + 2*M.MaxSocksaddrLength + 9 + } + return M.MaxSocksaddrLength + 4 +} + +func (c *ClientPacketConn) Upstream() any { + return c.Conn +} + +func Key(password string) [KeyLength]byte { + var key [KeyLength]byte + hash := sha256.New224() + common.Must1(hash.Write([]byte(password))) + hex.Encode(key[:], hash.Sum(nil)) + return key +} + +func ClientHandshakeRaw(conn net.Conn, key [KeyLength]byte, command byte, destination M.Socksaddr, payload []byte) error { + _, err := conn.Write(key[:]) + if err != nil { + return err + } + _, err = conn.Write(CRLF) + if err != nil { + return err + } + _, err = conn.Write([]byte{command}) + if err != nil { + return err + } + err = M.SocksaddrSerializer.WriteAddrPort(conn, destination) + if err != nil { + return err + } + _, err = conn.Write(CRLF) + if err != nil { + return err + } + if len(payload) > 0 { + _, err = conn.Write(payload) + if err != nil { + return err + } + } + return nil +} + +func ClientHandshake(conn net.Conn, key [KeyLength]byte, destination M.Socksaddr, payload []byte) error { + headerLen := KeyLength + M.SocksaddrSerializer.AddrPortLen(destination) + 5 + header := buf.NewSize(headerLen + len(payload)) + defer header.Release() + common.Must1(header.Write(key[:])) + common.Must1(header.Write(CRLF)) + common.Must(header.WriteByte(CommandTCP)) + err := M.SocksaddrSerializer.WriteAddrPort(header, destination) + if err != nil { + return err + } + common.Must1(header.Write(CRLF)) + common.Must1(header.Write(payload)) + _, err = conn.Write(header.Bytes()) + if err != nil { + return E.Cause(err, "write request") + } + return nil +} + +func ClientHandshakeBuffer(conn net.Conn, key [KeyLength]byte, destination M.Socksaddr, payload *buf.Buffer) error { + header := buf.With(payload.ExtendHeader(KeyLength + M.SocksaddrSerializer.AddrPortLen(destination) + 5)) + common.Must1(header.Write(key[:])) + common.Must1(header.Write(CRLF)) + common.Must(header.WriteByte(CommandTCP)) + err := M.SocksaddrSerializer.WriteAddrPort(header, destination) + if err != nil { + return err + } + common.Must1(header.Write(CRLF)) + + _, err = conn.Write(payload.Bytes()) + if err != nil { + return E.Cause(err, "write request") + } + return nil +} + +func ClientHandshakePacket(conn net.Conn, key [KeyLength]byte, destination M.Socksaddr, payload *buf.Buffer) error { + headerLen := KeyLength + 2*M.SocksaddrSerializer.AddrPortLen(destination) + 9 + payloadLen := payload.Len() + var header *buf.Buffer + var writeHeader bool + if payload.Start() >= headerLen { + header = buf.With(payload.ExtendHeader(headerLen)) + } else { + header = buf.NewSize(headerLen) + defer header.Release() + writeHeader = true + } + common.Must1(header.Write(key[:])) + common.Must1(header.Write(CRLF)) + common.Must(header.WriteByte(CommandUDP)) + err := M.SocksaddrSerializer.WriteAddrPort(header, destination) + if err != nil { + return err + } + common.Must1(header.Write(CRLF)) + common.Must(M.SocksaddrSerializer.WriteAddrPort(header, destination)) + common.Must(binary.Write(header, binary.BigEndian, uint16(payloadLen))) + common.Must1(header.Write(CRLF)) + + if writeHeader { + _, err := conn.Write(header.Bytes()) + if err != nil { + return E.Cause(err, "write request") + } + } + + _, err = conn.Write(payload.Bytes()) + if err != nil { + return E.Cause(err, "write payload") + } + return nil +} + +func ReadPacket(conn net.Conn, buffer *buf.Buffer) (M.Socksaddr, error) { + destination, err := M.SocksaddrSerializer.ReadAddrPort(conn) + if err != nil { + return M.Socksaddr{}, E.Cause(err, "read destination") + } + + var length uint16 + err = binary.Read(conn, binary.BigEndian, &length) + if err != nil { + return M.Socksaddr{}, E.Cause(err, "read chunk length") + } + + err = rw.SkipN(conn, 2) + if err != nil { + return M.Socksaddr{}, E.Cause(err, "skip crlf") + } + + _, err = buffer.ReadFullFrom(conn, int(length)) + return destination, err +} + +func WritePacket(conn net.Conn, buffer *buf.Buffer, destination M.Socksaddr) error { + defer buffer.Release() + bufferLen := buffer.Len() + header := buf.With(buffer.ExtendHeader(M.SocksaddrSerializer.AddrPortLen(destination) + 4)) + err := M.SocksaddrSerializer.WriteAddrPort(header, destination) + if err != nil { + return err + } + common.Must(binary.Write(header, binary.BigEndian, uint16(bufferLen))) + common.Must1(header.Write(CRLF)) + _, err = conn.Write(buffer.Bytes()) + if err != nil { + return E.Cause(err, "write packet") + } + return nil +} diff --git a/transport/trojan/protocol_wait.go b/transport/trojan/protocol_wait.go new file mode 100644 index 00000000..c6b4ec06 --- /dev/null +++ b/transport/trojan/protocol_wait.go @@ -0,0 +1,45 @@ +package trojan + +import ( + "encoding/binary" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" +) + +var _ N.PacketReadWaiter = (*ClientPacketConn)(nil) + +func (c *ClientPacketConn) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) { + c.readWaitOptions = options + return false +} + +func (c *ClientPacketConn) WaitReadPacket() (buffer *buf.Buffer, destination M.Socksaddr, err error) { + destination, err = M.SocksaddrSerializer.ReadAddrPort(c.Conn) + if err != nil { + return nil, M.Socksaddr{}, E.Cause(err, "read destination") + } + + var length uint16 + err = binary.Read(c.Conn, binary.BigEndian, &length) + if err != nil { + return nil, M.Socksaddr{}, E.Cause(err, "read chunk length") + } + + err = rw.SkipN(c.Conn, 2) + if err != nil { + return nil, M.Socksaddr{}, E.Cause(err, "skip crlf") + } + + buffer = c.readWaitOptions.NewPacketBuffer() + _, err = buffer.ReadFullFrom(c.Conn, int(length)) + if err != nil { + buffer.Release() + return + } + c.readWaitOptions.PostReturn(buffer) + return +} diff --git a/transport/trojan/service.go b/transport/trojan/service.go new file mode 100644 index 00000000..7f1803bb --- /dev/null +++ b/transport/trojan/service.go @@ -0,0 +1,149 @@ +package trojan + +import ( + "context" + "encoding/binary" + "net" + + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" +) + +type Handler interface { + N.TCPConnectionHandlerEx + N.UDPConnectionHandlerEx +} + +type Service[K comparable] struct { + users map[K][56]byte + keys map[[56]byte]K + handler Handler + fallbackHandler N.TCPConnectionHandlerEx + logger logger.ContextLogger +} + +func NewService[K comparable](handler Handler, fallbackHandler N.TCPConnectionHandlerEx, logger logger.ContextLogger) *Service[K] { + return &Service[K]{ + users: make(map[K][56]byte), + keys: make(map[[56]byte]K), + handler: handler, + fallbackHandler: fallbackHandler, + logger: logger, + } +} + +var ErrUserExists = E.New("user already exists") + +func (s *Service[K]) UpdateUsers(userList []K, passwordList []string) error { + users := make(map[K][56]byte) + keys := make(map[[56]byte]K) + for i, user := range userList { + if _, loaded := users[user]; loaded { + return ErrUserExists + } + key := Key(passwordList[i]) + if oldUser, loaded := keys[key]; loaded { + return E.Extend(ErrUserExists, "password used by ", oldUser) + } + users[user] = key + keys[key] = user + } + s.users = users + s.keys = keys + return nil +} + +func (s *Service[K]) NewConnection(ctx context.Context, conn net.Conn, source M.Socksaddr, onClose N.CloseHandlerFunc) error { + var key [KeyLength]byte + n, err := conn.Read(key[:]) + if err != nil { + return err + } else if n != KeyLength { + return s.fallback(ctx, conn, source, key[:n], E.New("bad request size"), onClose) + } + + if user, loaded := s.keys[key]; loaded { + ctx = auth.ContextWithUser(ctx, user) + } else { + return s.fallback(ctx, conn, source, key[:], E.New("bad request"), onClose) + } + + err = rw.SkipN(conn, 2) + if err != nil { + return E.Cause(err, "skip crlf") + } + + var command byte + err = binary.Read(conn, binary.BigEndian, &command) + if err != nil { + return E.Cause(err, "read command") + } + + switch command { + case CommandTCP, CommandUDP, CommandMux: + default: + return E.New("unknown command ", command) + } + + // var destination M.Socksaddr + destination, err := M.SocksaddrSerializer.ReadAddrPort(conn) + if err != nil { + return E.Cause(err, "read destination") + } + + err = rw.SkipN(conn, 2) + if err != nil { + return E.Cause(err, "skip crlf") + } + + switch command { + case CommandTCP: + s.handler.NewConnectionEx(ctx, conn, source, destination, onClose) + case CommandUDP: + s.handler.NewPacketConnectionEx(ctx, &PacketConn{Conn: conn}, source, destination, onClose) + // case CommandMux: + default: + return HandleMuxConnection(ctx, conn, source, s.handler, s.logger, onClose) + } + return nil +} + +func (s *Service[K]) fallback(ctx context.Context, conn net.Conn, source M.Socksaddr, header []byte, err error, onClose N.CloseHandlerFunc) error { + if s.fallbackHandler == nil { + return E.Extend(err, "fallback disabled") + } + conn = bufio.NewCachedConn(conn, buf.As(header).ToOwned()) + s.fallbackHandler.NewConnectionEx(ctx, conn, source, M.Socksaddr{}, onClose) + return nil +} + +type PacketConn struct { + net.Conn + readWaitOptions N.ReadWaitOptions +} + +func (c *PacketConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { + return ReadPacket(c.Conn, buffer) +} + +func (c *PacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + return WritePacket(c.Conn, buffer, destination) +} + +func (c *PacketConn) FrontHeadroom() int { + return M.MaxSocksaddrLength + 4 +} + +func (c *PacketConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *PacketConn) Upstream() any { + return c.Conn +} diff --git a/transport/trojan/service_wait.go b/transport/trojan/service_wait.go new file mode 100644 index 00000000..5ec082fe --- /dev/null +++ b/transport/trojan/service_wait.go @@ -0,0 +1,45 @@ +package trojan + +import ( + "encoding/binary" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" +) + +var _ N.PacketReadWaiter = (*PacketConn)(nil) + +func (c *PacketConn) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) { + c.readWaitOptions = options + return false +} + +func (c *PacketConn) WaitReadPacket() (buffer *buf.Buffer, destination M.Socksaddr, err error) { + destination, err = M.SocksaddrSerializer.ReadAddrPort(c.Conn) + if err != nil { + return nil, M.Socksaddr{}, E.Cause(err, "read destination") + } + + var length uint16 + err = binary.Read(c.Conn, binary.BigEndian, &length) + if err != nil { + return nil, M.Socksaddr{}, E.Cause(err, "read chunk length") + } + + err = rw.SkipN(c.Conn, 2) + if err != nil { + return nil, M.Socksaddr{}, E.Cause(err, "skip crlf") + } + + buffer = c.readWaitOptions.NewPacketBuffer() + _, err = buffer.ReadFullFrom(c.Conn, int(length)) + if err != nil { + buffer.Release() + return + } + c.readWaitOptions.PostReturn(buffer) + return +} diff --git a/transport/v2ray/grpc.go b/transport/v2ray/grpc.go new file mode 100644 index 00000000..1b4250ad --- /dev/null +++ b/transport/v2ray/grpc.go @@ -0,0 +1,30 @@ +//go:build with_grpc + +package v2ray + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2raygrpc" + "github.com/sagernet/sing-box/transport/v2raygrpclite" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func NewGRPCServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayGRPCOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) { + if options.ForceLite { + return v2raygrpclite.NewServer(ctx, logger, options, tlsConfig, handler) + } + return v2raygrpc.NewServer(ctx, logger, options, tlsConfig, handler) +} + +func NewGRPCClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayGRPCOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + if options.ForceLite { + return v2raygrpclite.NewClient(ctx, dialer, serverAddr, options, tlsConfig), nil + } + return v2raygrpc.NewClient(ctx, dialer, serverAddr, options, tlsConfig) +} diff --git a/transport/v2ray/grpc_lite.go b/transport/v2ray/grpc_lite.go new file mode 100644 index 00000000..4f2814a7 --- /dev/null +++ b/transport/v2ray/grpc_lite.go @@ -0,0 +1,23 @@ +//go:build !with_grpc + +package v2ray + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2raygrpclite" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func NewGRPCServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayGRPCOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) { + return v2raygrpclite.NewServer(ctx, logger, options, tlsConfig, handler) +} + +func NewGRPCClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayGRPCOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + return v2raygrpclite.NewClient(ctx, dialer, serverAddr, options, tlsConfig), nil +} diff --git a/transport/v2ray/quic.go b/transport/v2ray/quic.go new file mode 100644 index 00000000..4d3cdc6f --- /dev/null +++ b/transport/v2ray/quic.go @@ -0,0 +1,37 @@ +package v2ray + +import ( + "context" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var ( + quicServerConstructor ServerConstructor[option.V2RayQUICOptions] + quicClientConstructor ClientConstructor[option.V2RayQUICOptions] +) + +func RegisterQUICConstructor(server ServerConstructor[option.V2RayQUICOptions], client ClientConstructor[option.V2RayQUICOptions]) { + quicServerConstructor = server + quicClientConstructor = client +} + +func NewQUICServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayQUICOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) { + if quicServerConstructor == nil { + return nil, os.ErrInvalid + } + return quicServerConstructor(ctx, logger, options, tlsConfig, handler) +} + +func NewQUICClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + if quicClientConstructor == nil { + return nil, os.ErrInvalid + } + return quicClientConstructor(ctx, dialer, serverAddr, options, tlsConfig) +} diff --git a/transport/v2ray/transport.go b/transport/v2ray/transport.go new file mode 100644 index 00000000..ab52f55e --- /dev/null +++ b/transport/v2ray/transport.go @@ -0,0 +1,68 @@ +package v2ray + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing-box/transport/v2rayhttpupgrade" + "github.com/sagernet/sing-box/transport/v2raywebsocket" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type ( + ServerConstructor[O any] func(ctx context.Context, logger logger.ContextLogger, options O, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) + ClientConstructor[O any] func(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options O, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) +) + +func NewServerTransport(ctx context.Context, logger logger.ContextLogger, options option.V2RayTransportOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) { + if options.Type == "" { + return nil, nil + } + switch options.Type { + case C.V2RayTransportTypeHTTP: + return v2rayhttp.NewServer(ctx, logger, options.HTTPOptions, tlsConfig, handler) + case C.V2RayTransportTypeWebsocket: + return v2raywebsocket.NewServer(ctx, logger, options.WebsocketOptions, tlsConfig, handler) + case C.V2RayTransportTypeQUIC: + if tlsConfig == nil { + return nil, C.ErrTLSRequired + } + return NewQUICServer(ctx, logger, options.QUICOptions, tlsConfig, handler) + case C.V2RayTransportTypeGRPC: + return NewGRPCServer(ctx, logger, options.GRPCOptions, tlsConfig, handler) + case C.V2RayTransportTypeHTTPUpgrade: + return v2rayhttpupgrade.NewServer(ctx, logger, options.HTTPUpgradeOptions, tlsConfig, handler) + default: + return nil, E.New("unknown transport type: " + options.Type) + } +} + +func NewClientTransport(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayTransportOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + if options.Type == "" { + return nil, nil + } + switch options.Type { + case C.V2RayTransportTypeHTTP: + return v2rayhttp.NewClient(ctx, dialer, serverAddr, options.HTTPOptions, tlsConfig) + case C.V2RayTransportTypeGRPC: + return NewGRPCClient(ctx, dialer, serverAddr, options.GRPCOptions, tlsConfig) + case C.V2RayTransportTypeWebsocket: + return v2raywebsocket.NewClient(ctx, dialer, serverAddr, options.WebsocketOptions, tlsConfig) + case C.V2RayTransportTypeQUIC: + if tlsConfig == nil { + return nil, C.ErrTLSRequired + } + return NewQUICClient(ctx, dialer, serverAddr, options.QUICOptions, tlsConfig) + case C.V2RayTransportTypeHTTPUpgrade: + return v2rayhttpupgrade.NewClient(ctx, dialer, serverAddr, options.HTTPUpgradeOptions, tlsConfig) + default: + return nil, E.New("unknown transport type: " + options.Type) + } +} diff --git a/transport/v2raygrpc/client.go b/transport/v2raygrpc/client.go new file mode 100644 index 00000000..5af53856 --- /dev/null +++ b/transport/v2raygrpc/client.go @@ -0,0 +1,118 @@ +package v2raygrpc + +import ( + "context" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/http2" + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + ctx context.Context + dialer N.Dialer + serverAddr string + serviceName string + dialOptions []grpc.DialOption + conn atomic.Pointer[grpc.ClientConn] + connAccess sync.Mutex +} + +func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayGRPCOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + var dialOptions []grpc.DialOption + if tlsConfig != nil { + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http2.NextProtoTLS}) + } + dialOptions = append(dialOptions, grpc.WithTransportCredentials(NewTLSTransportCredentials(tlsConfig))) + } else { + dialOptions = append(dialOptions, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + if options.IdleTimeout > 0 { + dialOptions = append(dialOptions, grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: time.Duration(options.IdleTimeout), + Timeout: time.Duration(options.PingTimeout), + PermitWithoutStream: options.PermitWithoutStream, + })) + } + dialOptions = append(dialOptions, grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 500 * time.Millisecond, + Multiplier: 1.5, + Jitter: 0.2, + MaxDelay: 19 * time.Second, + }, + MinConnectTimeout: 5 * time.Second, + })) + dialOptions = append(dialOptions, grpc.WithContextDialer(func(ctx context.Context, server string) (net.Conn, error) { + return dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(server)) + })) + //nolint:staticcheck + dialOptions = append(dialOptions, grpc.WithReturnConnectionError()) + return &Client{ + ctx: ctx, + dialer: dialer, + serverAddr: serverAddr.String(), + serviceName: options.ServiceName, + dialOptions: dialOptions, + }, nil +} + +func (c *Client) connect() (*grpc.ClientConn, error) { + conn := c.conn.Load() + if conn != nil && conn.GetState() != connectivity.Shutdown { + return conn, nil + } + c.connAccess.Lock() + defer c.connAccess.Unlock() + conn = c.conn.Load() + if conn != nil && conn.GetState() != connectivity.Shutdown { + return conn, nil + } + //nolint:staticcheck + conn, err := grpc.DialContext(c.ctx, c.serverAddr, c.dialOptions...) + if err != nil { + return nil, err + } + c.conn.Store(conn) + return conn, nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + clientConn, err := c.connect() + if err != nil { + return nil, err + } + client := NewGunServiceClient(clientConn).(GunServiceCustomNameClient) + ctx, cancel := common.ContextWithCancelCause(ctx) + stream, err := client.TunCustomName(ctx, c.serviceName) + if err != nil { + cancel(err) + return nil, err + } + return NewGRPCConn(stream, cancel), nil +} + +func (c *Client) Close() error { + conn := c.conn.Swap(nil) + if conn != nil { + conn.Close() + } + return nil +} diff --git a/transport/v2raygrpc/conn.go b/transport/v2raygrpc/conn.go new file mode 100644 index 00000000..87be9661 --- /dev/null +++ b/transport/v2raygrpc/conn.go @@ -0,0 +1,106 @@ +package v2raygrpc + +import ( + "context" + "net" + "os" + "sync" + "time" + + "github.com/sagernet/sing/common/baderror" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ net.Conn = (*GRPCConn)(nil) + +type GRPCConn struct { + GunService + cache []byte + cancel context.CancelCauseFunc + closeOnce sync.Once +} + +func NewGRPCConn(service GunService, cancel context.CancelCauseFunc) *GRPCConn { + //nolint:staticcheck + if client, isClient := service.(GunService_TunClient); isClient { + service = &clientConnWrapper{client} + } + return &GRPCConn{ + GunService: service, + cancel: cancel, + } +} + +func (c *GRPCConn) Read(b []byte) (n int, err error) { + if len(c.cache) > 0 { + n = copy(b, c.cache) + c.cache = c.cache[n:] + return + } + hunk, err := c.Recv() + err = baderror.WrapGRPC(err) + if err != nil { + return + } + n = copy(b, hunk.Data) + if n < len(hunk.Data) { + c.cache = hunk.Data[n:] + } + return +} + +func (c *GRPCConn) Write(b []byte) (n int, err error) { + err = baderror.WrapGRPC(c.Send(&Hunk{Data: b})) + if err != nil { + return + } + return len(b), nil +} + +func (c *GRPCConn) Close() error { + c.closeOnce.Do(func() { + if c.cancel != nil { + c.cancel(nil) + } + }) + return nil +} + +func (c *GRPCConn) LocalAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *GRPCConn) RemoteAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *GRPCConn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *GRPCConn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *GRPCConn) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *GRPCConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *GRPCConn) Upstream() any { + return c.GunService +} + +var _ N.WriteCloser = (*clientConnWrapper)(nil) + +type clientConnWrapper struct { + GunService_TunClient +} + +func (c *clientConnWrapper) CloseWrite() error { + return c.CloseSend() +} diff --git a/transport/v2raygrpc/credentials/credentials.go b/transport/v2raygrpc/credentials/credentials.go new file mode 100644 index 00000000..32c9b590 --- /dev/null +++ b/transport/v2raygrpc/credentials/credentials.go @@ -0,0 +1,49 @@ +/* + * Copyright 2021 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package credentials + +import ( + "context" +) + +// requestInfoKey is a struct to be used as the key to store RequestInfo in a +// context. +type requestInfoKey struct{} + +// NewRequestInfoContext creates a context with ri. +func NewRequestInfoContext(ctx context.Context, ri interface{}) context.Context { + return context.WithValue(ctx, requestInfoKey{}, ri) +} + +// RequestInfoFromContext extracts the RequestInfo from ctx. +func RequestInfoFromContext(ctx context.Context) interface{} { + return ctx.Value(requestInfoKey{}) +} + +// clientHandshakeInfoKey is a struct used as the key to store +// ClientHandshakeInfo in a context. +type clientHandshakeInfoKey struct{} + +// ClientHandshakeInfoFromContext extracts the ClientHandshakeInfo from ctx. +func ClientHandshakeInfoFromContext(ctx context.Context) interface{} { + return ctx.Value(clientHandshakeInfoKey{}) +} + +// NewClientHandshakeInfoContext creates a context with chi. +func NewClientHandshakeInfoContext(ctx context.Context, chi interface{}) context.Context { + return context.WithValue(ctx, clientHandshakeInfoKey{}, chi) +} diff --git a/transport/v2raygrpc/credentials/spiffe.go b/transport/v2raygrpc/credentials/spiffe.go new file mode 100644 index 00000000..25ade623 --- /dev/null +++ b/transport/v2raygrpc/credentials/spiffe.go @@ -0,0 +1,75 @@ +/* + * + * Copyright 2020 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Package credentials defines APIs for parsing SPIFFE ID. +// +// All APIs in this package are experimental. +package credentials + +import ( + "crypto/tls" + "crypto/x509" + "net/url" + + "google.golang.org/grpc/grpclog" +) + +var logger = grpclog.Component("credentials") + +// SPIFFEIDFromState parses the SPIFFE ID from State. If the SPIFFE ID format +// is invalid, return nil with warning. +func SPIFFEIDFromState(state tls.ConnectionState) *url.URL { + if len(state.PeerCertificates) == 0 || len(state.PeerCertificates[0].URIs) == 0 { + return nil + } + return SPIFFEIDFromCert(state.PeerCertificates[0]) +} + +// SPIFFEIDFromCert parses the SPIFFE ID from x509.Certificate. If the SPIFFE +// ID format is invalid, return nil with warning. +func SPIFFEIDFromCert(cert *x509.Certificate) *url.URL { + if cert == nil || cert.URIs == nil { + return nil + } + var spiffeID *url.URL + for _, uri := range cert.URIs { + if uri == nil || uri.Scheme != "spiffe" || uri.Opaque != "" || (uri.User != nil && uri.User.Username() != "") { + continue + } + // From this point, we assume the uri is intended for a SPIFFE ID. + if len(uri.String()) > 2048 { + logger.Warning("invalid SPIFFE ID: total ID length larger than 2048 bytes") + return nil + } + if len(uri.Host) == 0 || len(uri.Path) == 0 { + logger.Warning("invalid SPIFFE ID: domain or workload ID is empty") + return nil + } + if len(uri.Host) > 255 { + logger.Warning("invalid SPIFFE ID: domain length larger than 255 characters") + return nil + } + // A valid SPIFFE certificate can only have exactly one URI SAN field. + if len(cert.URIs) > 1 { + logger.Warning("invalid SPIFFE ID: multiple URI SANs") + return nil + } + spiffeID = uri + } + return spiffeID +} diff --git a/transport/v2raygrpc/credentials/syscallconn.go b/transport/v2raygrpc/credentials/syscallconn.go new file mode 100644 index 00000000..2919632d --- /dev/null +++ b/transport/v2raygrpc/credentials/syscallconn.go @@ -0,0 +1,58 @@ +/* + * + * Copyright 2018 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package credentials + +import ( + "net" + "syscall" +) + +type sysConn = syscall.Conn + +// syscallConn keeps reference of rawConn to support syscall.Conn for channelz. +// SyscallConn() (the method in interface syscall.Conn) is explicitly +// implemented on this type, +// +// Interface syscall.Conn is implemented by most net.Conn implementations (e.g. +// TCPConn, UnixConn), but is not part of net.Conn interface. So wrapper conns +// that embed net.Conn don't implement syscall.Conn. (Side note: tls.Conn +// doesn't embed net.Conn, so even if syscall.Conn is part of net.Conn, it won't +// help here). +type syscallConn struct { + net.Conn + // sysConn is a type alias of syscall.Conn. It's necessary because the name + // `Conn` collides with `net.Conn`. + sysConn +} + +// WrapSyscallConn tries to wrap rawConn and newConn into a net.Conn that +// implements syscall.Conn. rawConn will be used to support syscall, and newConn +// will be used for read/write. +// +// This function returns newConn if rawConn doesn't implement syscall.Conn. +func WrapSyscallConn(rawConn, newConn net.Conn) net.Conn { + sysConn, ok := rawConn.(syscall.Conn) + if !ok { + return newConn + } + return &syscallConn{ + Conn: newConn, + sysConn: sysConn, + } +} diff --git a/transport/v2raygrpc/credentials/util.go b/transport/v2raygrpc/credentials/util.go new file mode 100644 index 00000000..f792fd22 --- /dev/null +++ b/transport/v2raygrpc/credentials/util.go @@ -0,0 +1,52 @@ +/* + * + * Copyright 2020 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package credentials + +import ( + "crypto/tls" +) + +const alpnProtoStrH2 = "h2" + +// AppendH2ToNextProtos appends h2 to next protos. +func AppendH2ToNextProtos(ps []string) []string { + for _, p := range ps { + if p == alpnProtoStrH2 { + return ps + } + } + ret := make([]string, 0, len(ps)+1) + ret = append(ret, ps...) + return append(ret, alpnProtoStrH2) +} + +// CloneTLSConfig returns a shallow clone of the exported +// fields of cfg, ignoring the unexported sync.Once, which +// contains a mutex and must not be copied. +// +// If cfg is nil, a new zero tls.Config is returned. +// +// TODO: inline this function if possible. +func CloneTLSConfig(cfg *tls.Config) *tls.Config { + if cfg == nil { + return &tls.Config{} + } + + return cfg.Clone() +} diff --git a/transport/v2raygrpc/custom_name.go b/transport/v2raygrpc/custom_name.go new file mode 100644 index 00000000..ce970dc6 --- /dev/null +++ b/transport/v2raygrpc/custom_name.go @@ -0,0 +1,51 @@ +package v2raygrpc + +import ( + "context" + + "google.golang.org/grpc" +) + +type GunService interface { + Context() context.Context + Send(*Hunk) error + Recv() (*Hunk, error) +} + +func ServerDesc(name string) grpc.ServiceDesc { + return grpc.ServiceDesc{ + ServiceName: name, + HandlerType: (*GunServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Tun", + Handler: _GunService_Tun_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "gun.proto", + } +} + +func (c *gunServiceClient) TunCustomName(ctx context.Context, name string, opts ...grpc.CallOption) (GunService_TunClient, error) { + stream, err := c.cc.NewStream(ctx, &ServerDesc(name).Streams[0], "/"+name+"/Tun", opts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[Hunk, Hunk]{ClientStream: stream} + return x, nil +} + +var _ GunServiceCustomNameClient = (*gunServiceClient)(nil) + +type GunServiceCustomNameClient interface { + TunCustomName(ctx context.Context, name string, opts ...grpc.CallOption) (GunService_TunClient, error) + Tun(ctx context.Context, opts ...grpc.CallOption) (GunService_TunClient, error) +} + +func RegisterGunServiceCustomNameServer(s *grpc.Server, srv GunServiceServer, name string) { + desc := ServerDesc(name) + s.RegisterService(&desc, srv) +} diff --git a/transport/v2raygrpc/server.go b/transport/v2raygrpc/server.go new file mode 100644 index 00000000..4d426aa1 --- /dev/null +++ b/transport/v2raygrpc/server.go @@ -0,0 +1,97 @@ +package v2raygrpc + +import ( + "context" + "net" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/http2" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + gM "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + handler adapter.V2RayServerTransportHandler + server *grpc.Server +} + +func NewServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayGRPCOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (*Server, error) { + var serverOptions []grpc.ServerOption + if tlsConfig != nil { + if !common.Contains(tlsConfig.NextProtos(), http2.NextProtoTLS) { + tlsConfig.SetNextProtos(append([]string{"h2"}, tlsConfig.NextProtos()...)) + } + serverOptions = append(serverOptions, grpc.Creds(NewTLSTransportCredentials(tlsConfig))) + } + if options.IdleTimeout > 0 { + serverOptions = append(serverOptions, grpc.KeepaliveParams(keepalive.ServerParameters{ + Time: time.Duration(options.IdleTimeout), + Timeout: time.Duration(options.PingTimeout), + })) + } + server := &Server{ctx, logger, handler, grpc.NewServer(serverOptions...)} + RegisterGunServiceCustomNameServer(server.server, server, options.ServiceName) + return server, nil +} + +func (s *Server) Tun(server GunService_TunServer) error { + conn := NewGRPCConn(server, nil) + var source M.Socksaddr + if remotePeer, loaded := peer.FromContext(server.Context()); loaded { + source = M.SocksaddrFromNet(remotePeer.Addr) + } + if grpcMetadata, loaded := gM.FromIncomingContext(server.Context()); loaded { + forwardFrom := strings.Join(grpcMetadata.Get("X-Forwarded-For"), ",") + if forwardFrom != "" { + for _, from := range strings.Split(forwardFrom, ",") { + originAddr := M.ParseSocksaddr(from) + if originAddr.IsValid() { + source = originAddr.Unwrap() + } + } + } + } + done := make(chan struct{}) + go s.handler.NewConnectionEx(log.ContextWithNewID(s.ctx), conn, source, M.Socksaddr{}, N.OnceClose(func(it error) { + close(done) + })) + <-done + return nil +} + +func (s *Server) mustEmbedUnimplementedGunServiceServer() { +} + +func (s *Server) Network() []string { + return []string{N.NetworkTCP} +} + +func (s *Server) Serve(listener net.Listener) error { + return s.server.Serve(listener) +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + return os.ErrInvalid +} + +func (s *Server) Close() error { + s.server.Stop() + return nil +} diff --git a/transport/v2raygrpc/stream.pb.go b/transport/v2raygrpc/stream.pb.go new file mode 100644 index 00000000..9576c739 --- /dev/null +++ b/transport/v2raygrpc/stream.pb.go @@ -0,0 +1,125 @@ +package v2raygrpc + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Hunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hunk) Reset() { + *x = Hunk{} + mi := &file_transport_v2raygrpc_stream_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hunk) ProtoMessage() {} + +func (x *Hunk) ProtoReflect() protoreflect.Message { + mi := &file_transport_v2raygrpc_stream_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hunk.ProtoReflect.Descriptor instead. +func (*Hunk) Descriptor() ([]byte, []int) { + return file_transport_v2raygrpc_stream_proto_rawDescGZIP(), []int{0} +} + +func (x *Hunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +var File_transport_v2raygrpc_stream_proto protoreflect.FileDescriptor + +const file_transport_v2raygrpc_stream_proto_rawDesc = "" + + "\n" + + " transport/v2raygrpc/stream.proto\x12\x13transport.v2raygrpc\"\x1a\n" + + "\x04Hunk\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data2M\n" + + "\n" + + "GunService\x12?\n" + + "\x03Tun\x12\x19.transport.v2raygrpc.Hunk\x1a\x19.transport.v2raygrpc.Hunk(\x010\x01B2Z0github.com/sagernet/sing-box/transport/v2raygrpcb\x06proto3" + +var ( + file_transport_v2raygrpc_stream_proto_rawDescOnce sync.Once + file_transport_v2raygrpc_stream_proto_rawDescData []byte +) + +func file_transport_v2raygrpc_stream_proto_rawDescGZIP() []byte { + file_transport_v2raygrpc_stream_proto_rawDescOnce.Do(func() { + file_transport_v2raygrpc_stream_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_v2raygrpc_stream_proto_rawDesc), len(file_transport_v2raygrpc_stream_proto_rawDesc))) + }) + return file_transport_v2raygrpc_stream_proto_rawDescData +} + +var ( + file_transport_v2raygrpc_stream_proto_msgTypes = make([]protoimpl.MessageInfo, 1) + file_transport_v2raygrpc_stream_proto_goTypes = []any{ + (*Hunk)(nil), // 0: transport.v2raygrpc.Hunk + } +) + +var file_transport_v2raygrpc_stream_proto_depIdxs = []int32{ + 0, // 0: transport.v2raygrpc.GunService.Tun:input_type -> transport.v2raygrpc.Hunk + 0, // 1: transport.v2raygrpc.GunService.Tun:output_type -> transport.v2raygrpc.Hunk + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_transport_v2raygrpc_stream_proto_init() } +func file_transport_v2raygrpc_stream_proto_init() { + if File_transport_v2raygrpc_stream_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_v2raygrpc_stream_proto_rawDesc), len(file_transport_v2raygrpc_stream_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_transport_v2raygrpc_stream_proto_goTypes, + DependencyIndexes: file_transport_v2raygrpc_stream_proto_depIdxs, + MessageInfos: file_transport_v2raygrpc_stream_proto_msgTypes, + }.Build() + File_transport_v2raygrpc_stream_proto = out.File + file_transport_v2raygrpc_stream_proto_goTypes = nil + file_transport_v2raygrpc_stream_proto_depIdxs = nil +} diff --git a/transport/v2raygrpc/stream.proto b/transport/v2raygrpc/stream.proto new file mode 100644 index 00000000..514072c0 --- /dev/null +++ b/transport/v2raygrpc/stream.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package transport.v2raygrpc; +option go_package = "github.com/sagernet/sing-box/transport/v2raygrpc"; + +message Hunk { + bytes data = 1; +} + +service GunService { + rpc Tun (stream Hunk) returns (stream Hunk); +} diff --git a/transport/v2raygrpc/stream_grpc.pb.go b/transport/v2raygrpc/stream_grpc.pb.go new file mode 100644 index 00000000..21cc3279 --- /dev/null +++ b/transport/v2raygrpc/stream_grpc.pb.go @@ -0,0 +1,110 @@ +package v2raygrpc + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GunService_Tun_FullMethodName = "/transport.v2raygrpc.GunService/Tun" +) + +// GunServiceClient is the client API for GunService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type GunServiceClient interface { + Tun(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Hunk, Hunk], error) +} + +type gunServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGunServiceClient(cc grpc.ClientConnInterface) GunServiceClient { + return &gunServiceClient{cc} +} + +func (c *gunServiceClient) Tun(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Hunk, Hunk], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GunService_ServiceDesc.Streams[0], GunService_Tun_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[Hunk, Hunk]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GunService_TunClient = grpc.BidiStreamingClient[Hunk, Hunk] + +// GunServiceServer is the server API for GunService service. +// All implementations must embed UnimplementedGunServiceServer +// for forward compatibility. +type GunServiceServer interface { + Tun(grpc.BidiStreamingServer[Hunk, Hunk]) error + mustEmbedUnimplementedGunServiceServer() +} + +// UnimplementedGunServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGunServiceServer struct{} + +func (UnimplementedGunServiceServer) Tun(grpc.BidiStreamingServer[Hunk, Hunk]) error { + return status.Error(codes.Unimplemented, "method Tun not implemented") +} +func (UnimplementedGunServiceServer) mustEmbedUnimplementedGunServiceServer() {} +func (UnimplementedGunServiceServer) testEmbeddedByValue() {} + +// UnsafeGunServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GunServiceServer will +// result in compilation errors. +type UnsafeGunServiceServer interface { + mustEmbedUnimplementedGunServiceServer() +} + +func RegisterGunServiceServer(s grpc.ServiceRegistrar, srv GunServiceServer) { + // If the following call panics, it indicates UnimplementedGunServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GunService_ServiceDesc, srv) +} + +func _GunService_Tun_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GunServiceServer).Tun(&grpc.GenericServerStream[Hunk, Hunk]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GunService_TunServer = grpc.BidiStreamingServer[Hunk, Hunk] + +// GunService_ServiceDesc is the grpc.ServiceDesc for GunService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GunService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "transport.v2raygrpc.GunService", + HandlerType: (*GunServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Tun", + Handler: _GunService_Tun_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "transport/v2raygrpc/stream.proto", +} diff --git a/transport/v2raygrpc/tls_credentials.go b/transport/v2raygrpc/tls_credentials.go new file mode 100644 index 00000000..53a3b7ab --- /dev/null +++ b/transport/v2raygrpc/tls_credentials.go @@ -0,0 +1,86 @@ +package v2raygrpc + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/common/tls" + internal_credentials "github.com/sagernet/sing-box/transport/v2raygrpc/credentials" + + "google.golang.org/grpc/credentials" +) + +type TLSTransportCredentials struct { + config tls.Config +} + +func NewTLSTransportCredentials(config tls.Config) credentials.TransportCredentials { + return &TLSTransportCredentials{config} +} + +func (c *TLSTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "tls", + SecurityVersion: "1.2", + ServerName: c.config.ServerName(), + } +} + +func (c *TLSTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + cfg := c.config.Clone() + if cfg.ServerName() == "" { + serverName, _, err := net.SplitHostPort(authority) + if err != nil { + serverName = authority + } + cfg.SetServerName(serverName) + } + conn, err := tls.ClientHandshake(ctx, rawConn, cfg) + if err != nil { + return nil, nil, err + } + tlsInfo := credentials.TLSInfo{ + State: conn.ConnectionState(), + CommonAuthInfo: credentials.CommonAuthInfo{ + SecurityLevel: credentials.PrivacyAndIntegrity, + }, + } + id := internal_credentials.SPIFFEIDFromState(conn.ConnectionState()) + if id != nil { + tlsInfo.SPIFFEID = id + } + return internal_credentials.WrapSyscallConn(rawConn, conn), tlsInfo, nil +} + +func (c *TLSTransportCredentials) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + serverConfig, isServer := c.config.(tls.ServerConfig) + if !isServer { + return nil, nil, os.ErrInvalid + } + conn, err := tls.ServerHandshake(context.Background(), rawConn, serverConfig) + if err != nil { + rawConn.Close() + return nil, nil, err + } + tlsInfo := credentials.TLSInfo{ + State: conn.ConnectionState(), + CommonAuthInfo: credentials.CommonAuthInfo{ + SecurityLevel: credentials.PrivacyAndIntegrity, + }, + } + id := internal_credentials.SPIFFEIDFromState(conn.ConnectionState()) + if id != nil { + tlsInfo.SPIFFEID = id + } + return internal_credentials.WrapSyscallConn(rawConn, conn), tlsInfo, nil +} + +func (c *TLSTransportCredentials) Clone() credentials.TransportCredentials { + return NewTLSTransportCredentials(c.config) +} + +func (c *TLSTransportCredentials) OverrideServerName(serverNameOverride string) error { + c.config.SetServerName(serverNameOverride) + return nil +} diff --git a/transport/v2raygrpclite/client.go b/transport/v2raygrpclite/client.go new file mode 100644 index 00000000..b2aab911 --- /dev/null +++ b/transport/v2raygrpclite/client.go @@ -0,0 +1,108 @@ +package v2raygrpclite + +import ( + "context" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/http2" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +var defaultClientHeader = http.Header{ + "Content-Type": []string{"application/grpc"}, + "User-Agent": []string{"grpc-go/1.48.0"}, + "TE": []string{"trailers"}, +} + +type Client struct { + ctx context.Context + serverAddr M.Socksaddr + transport *http2.Transport + options option.V2RayGRPCOptions + url *url.URL + host string +} + +func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayGRPCOptions, tlsConfig tls.Config) adapter.V2RayClientTransport { + var host string + if tlsConfig != nil && tlsConfig.ServerName() != "" { + host = M.ParseSocksaddrHostPort(tlsConfig.ServerName(), serverAddr.Port).String() + } else { + host = serverAddr.String() + } + client := &Client{ + ctx: ctx, + serverAddr: serverAddr, + options: options, + transport: &http2.Transport{ + ReadIdleTimeout: time.Duration(options.IdleTimeout), + PingTimeout: time.Duration(options.PingTimeout), + DisableCompression: true, + }, + url: &url.URL{ + Scheme: "https", + Host: serverAddr.String(), + Path: "/" + options.ServiceName + "/Tun", + RawPath: "/" + url.PathEscape(options.ServiceName) + "/Tun", + }, + host: host, + } + if tlsConfig == nil { + client.transport.DialTLSContext = func(ctx context.Context, network, addr string, cfg *tls.STDConfig) (net.Conn, error) { + return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + } + } else { + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http2.NextProtoTLS}) + } + tlsDialer := tls.NewDialer(dialer, tlsConfig) + client.transport.DialTLSContext = func(ctx context.Context, network, addr string, cfg *tls.STDConfig) (net.Conn, error) { + return tlsDialer.DialTLSContext(ctx, M.ParseSocksaddr(addr)) + } + } + + return client +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + pipeInReader, pipeInWriter := io.Pipe() + request := &http.Request{ + Method: http.MethodPost, + Body: pipeInReader, + URL: c.url, + Header: defaultClientHeader, + Host: c.host, + } + request = request.WithContext(ctx) + conn := newLateGunConn(pipeInWriter) + go func() { + response, err := c.transport.RoundTrip(request) + if err != nil { + conn.setup(nil, err) + } else if response.StatusCode != 200 { + response.Body.Close() + conn.setup(nil, E.New("v2ray-grpc: unexpected status: ", response.Status)) + } else { + conn.setup(response.Body, nil) + } + }() + return conn, nil +} + +func (c *Client) Close() error { + v2rayhttp.ResetTransport(c.transport) + return nil +} diff --git a/transport/v2raygrpclite/conn.go b/transport/v2raygrpclite/conn.go new file mode 100644 index 00000000..5feafbb6 --- /dev/null +++ b/transport/v2raygrpclite/conn.go @@ -0,0 +1,169 @@ +package v2raygrpclite + +import ( + std_bufio "bufio" + "encoding/binary" + "io" + "net" + "net/http" + "os" + "time" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/baderror" + "github.com/sagernet/sing/common/buf" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/varbin" +) + +// kanged from: https://github.com/Qv2ray/gun-lite + +var _ net.Conn = (*GunConn)(nil) + +type GunConn struct { + rawReader io.Reader + reader *std_bufio.Reader + writer io.Writer + flusher http.Flusher + create chan struct{} + err error + readRemaining int +} + +func newGunConn(reader io.Reader, writer io.Writer, flusher http.Flusher) *GunConn { + return &GunConn{ + rawReader: reader, + reader: std_bufio.NewReader(reader), + writer: writer, + flusher: flusher, + } +} + +func newLateGunConn(writer io.Writer) *GunConn { + return &GunConn{ + create: make(chan struct{}), + writer: writer, + } +} + +func (c *GunConn) setup(reader io.Reader, err error) { + if reader != nil { + c.rawReader = reader + c.reader = std_bufio.NewReader(reader) + } + c.err = err + close(c.create) +} + +func (c *GunConn) Read(b []byte) (n int, err error) { + n, err = c.read(b) + return n, baderror.WrapH2(err) +} + +func (c *GunConn) read(b []byte) (n int, err error) { + if c.reader == nil { + <-c.create + if c.err != nil { + return 0, c.err + } + } + + if c.readRemaining > 0 { + if len(b) > c.readRemaining { + b = b[:c.readRemaining] + } + n, err = c.reader.Read(b) + c.readRemaining -= n + return + } + + _, err = c.reader.Discard(6) + if err != nil { + return + } + + dataLen, err := binary.ReadUvarint(c.reader) + if err != nil { + return + } + + readLen := int(dataLen) + c.readRemaining = readLen + if len(b) > readLen { + b = b[:readLen] + } + + n, err = c.reader.Read(b) + c.readRemaining -= n + return +} + +func (c *GunConn) Write(b []byte) (n int, err error) { + varLen := varbin.UvarintLen(uint64(len(b))) + buffer := buf.NewSize(6 + varLen + len(b)) + header := buffer.Extend(6 + varLen) + header[0] = 0x00 + binary.BigEndian.PutUint32(header[1:5], uint32(1+varLen+len(b))) + header[5] = 0x0A + binary.PutUvarint(header[6:], uint64(len(b))) + common.Must1(buffer.Write(b)) + _, err = c.writer.Write(buffer.Bytes()) + if err != nil { + return 0, baderror.WrapH2(err) + } + if c.flusher != nil { + c.flusher.Flush() + } + return len(b), nil +} + +func (c *GunConn) WriteBuffer(buffer *buf.Buffer) error { + defer buffer.Release() + dataLen := buffer.Len() + varLen := varbin.UvarintLen(uint64(dataLen)) + header := buffer.ExtendHeader(6 + varLen) + header[0] = 0x00 + binary.BigEndian.PutUint32(header[1:5], uint32(1+varLen+dataLen)) + header[5] = 0x0A + binary.PutUvarint(header[6:], uint64(dataLen)) + err := common.Error(c.writer.Write(buffer.Bytes())) + if err != nil { + return baderror.WrapH2(err) + } + if c.flusher != nil { + c.flusher.Flush() + } + return nil +} + +func (c *GunConn) FrontHeadroom() int { + return 6 + binary.MaxVarintLen64 +} + +func (c *GunConn) Close() error { + return common.Close(c.rawReader, c.writer) +} + +func (c *GunConn) LocalAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *GunConn) RemoteAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *GunConn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *GunConn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *GunConn) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *GunConn) NeedAdditionalReadDeadline() bool { + return true +} diff --git a/transport/v2raygrpclite/server.go b/transport/v2raygrpclite/server.go new file mode 100644 index 00000000..622d785a --- /dev/null +++ b/transport/v2raygrpclite/server.go @@ -0,0 +1,119 @@ +package v2raygrpclite + +import ( + "context" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + sHttp "github.com/sagernet/sing/protocol/http" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + tlsConfig tls.ServerConfig + logger logger.ContextLogger + handler adapter.V2RayServerTransportHandler + httpServer *http.Server + h2Server *http2.Server + h2cHandler http.Handler + path string +} + +func NewServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayGRPCOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (*Server, error) { + server := &Server{ + tlsConfig: tlsConfig, + logger: logger, + handler: handler, + path: "/" + options.ServiceName + "/Tun", + h2Server: &http2.Server{ + IdleTimeout: time.Duration(options.IdleTimeout), + }, + } + server.httpServer = &http.Server{ + Handler: server, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return log.ContextWithNewID(ctx) + }, + } + server.h2cHandler = h2c.NewHandler(server, server.h2Server) + return server, nil +} + +func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if request.Method == "PRI" && len(request.Header) == 0 && request.URL.Path == "*" && request.Proto == "HTTP/2.0" { + s.h2cHandler.ServeHTTP(writer, request) + return + } + if request.URL.Path != s.path { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad path: ", request.URL.Path)) + return + } + if request.Method != http.MethodPost { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad method: ", request.Method)) + return + } + if ct := request.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/grpc") { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad content type: ", ct)) + return + } + writer.Header().Set("Content-Type", "application/grpc") + writer.Header().Set("TE", "trailers") + writer.WriteHeader(http.StatusOK) + done := make(chan struct{}) + conn := v2rayhttp.NewHTTP2Wrapper(newGunConn(request.Body, writer, writer.(http.Flusher))) + s.handler.NewConnectionEx(request.Context(), conn, sHttp.SourceAddress(request), M.Socksaddr{}, N.OnceClose(func(it error) { + close(done) + })) + <-done + conn.CloseWrapper() +} + +func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { + if statusCode > 0 { + writer.WriteHeader(statusCode) + } + s.logger.ErrorContext(request.Context(), E.Cause(err, "process connection from ", request.RemoteAddr)) +} + +func (s *Server) Network() []string { + return []string{N.NetworkTCP} +} + +func (s *Server) Serve(listener net.Listener) error { + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + listener = aTLS.NewListener(listener, s.tlsConfig) + } + return s.httpServer.Serve(listener) +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + return os.ErrInvalid +} + +func (s *Server) Close() error { + return common.Close(common.PtrOrNil(s.httpServer)) +} diff --git a/transport/v2rayhttp/client.go b/transport/v2rayhttp/client.go new file mode 100644 index 00000000..6c327cd6 --- /dev/null +++ b/transport/v2rayhttp/client.go @@ -0,0 +1,157 @@ +package v2rayhttp + +import ( + "context" + "io" + "math/rand" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + sHTTP "github.com/sagernet/sing/protocol/http" + + "golang.org/x/net/http2" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + ctx context.Context + dialer N.Dialer + serverAddr M.Socksaddr + transport http.RoundTripper + http2 bool + requestURL url.URL + host []string + method string + headers http.Header +} + +func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayHTTPOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + var transport http.RoundTripper + if tlsConfig == nil { + transport = &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + } + } else { + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http2.NextProtoTLS}) + } + tlsDialer := tls.NewDialer(dialer, tlsConfig) + transport = &http2.Transport{ + ReadIdleTimeout: time.Duration(options.IdleTimeout), + PingTimeout: time.Duration(options.PingTimeout), + DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.STDConfig) (net.Conn, error) { + return tlsDialer.DialTLSContext(ctx, M.ParseSocksaddr(addr)) + }, + } + } + if options.Method == "" { + options.Method = http.MethodPut + } + var requestURL url.URL + if tlsConfig == nil { + requestURL.Scheme = "http" + } else { + requestURL.Scheme = "https" + } + requestURL.Host = serverAddr.String() + requestURL.Path = options.Path + err := sHTTP.URLSetPath(&requestURL, options.Path) + if err != nil { + return nil, E.Cause(err, "parse path") + } + if !strings.HasPrefix(requestURL.Path, "/") { + requestURL.Path = "/" + requestURL.Path + } + return &Client{ + ctx: ctx, + dialer: dialer, + serverAddr: serverAddr, + requestURL: requestURL, + host: options.Host, + method: options.Method, + headers: options.Headers.Build(), + transport: transport, + http2: tlsConfig != nil, + }, nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + if !c.http2 { + return c.dialHTTP(ctx) + } else { + return c.dialHTTP2(ctx) + } +} + +func (c *Client) dialHTTP(ctx context.Context) (net.Conn, error) { + conn, err := c.dialer.DialContext(ctx, N.NetworkTCP, c.serverAddr) + if err != nil { + return nil, err + } + + request := &http.Request{ + Method: c.method, + URL: &c.requestURL, + Header: c.headers.Clone(), + } + switch hostLen := len(c.host); hostLen { + case 0: + request.Host = c.serverAddr.AddrString() + case 1: + request.Host = c.host[0] + default: + request.Host = c.host[rand.Intn(hostLen)] + } + + return NewHTTP1Conn(conn, request), nil +} + +func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { + pipeInReader, pipeInWriter := io.Pipe() + request := &http.Request{ + Method: c.method, + Body: pipeInReader, + URL: &c.requestURL, + Header: c.headers.Clone(), + } + request = request.WithContext(ctx) + switch hostLen := len(c.host); hostLen { + case 0: + // https://github.com/v2fly/v2ray-core/blob/master/transport/internet/http/config.go#L13 + request.Host = "www.example.com" + case 1: + request.Host = c.host[0] + default: + request.Host = c.host[rand.Intn(hostLen)] + } + conn := NewLateHTTPConn(pipeInWriter) + go func() { + response, err := c.transport.RoundTrip(request) + if err != nil { + conn.Setup(nil, err) + } else if response.StatusCode != 200 { + response.Body.Close() + conn.Setup(nil, E.New("v2ray-http: unexpected status: ", response.Status)) + } else { + conn.Setup(response.Body, nil) + } + }() + return conn, nil +} + +func (c *Client) Close() error { + c.transport = ResetTransport(c.transport) + return nil +} diff --git a/transport/v2rayhttp/conn.go b/transport/v2rayhttp/conn.go new file mode 100644 index 00000000..b339a753 --- /dev/null +++ b/transport/v2rayhttp/conn.go @@ -0,0 +1,267 @@ +package v2rayhttp + +import ( + std_bufio "bufio" + "context" + "io" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/baderror" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type HTTPConn struct { + net.Conn + request *http.Request + requestWritten bool + responseRead bool + responseCache *buf.Buffer +} + +func NewHTTP1Conn(conn net.Conn, request *http.Request) *HTTPConn { + if request.Header.Get("Host") == "" { + request.Header.Set("Host", request.Host) + } + return &HTTPConn{ + Conn: conn, + request: request, + } +} + +func (c *HTTPConn) Read(b []byte) (n int, err error) { + if !c.responseRead { + reader := std_bufio.NewReader(c.Conn) + response, err := http.ReadResponse(reader, c.request) + if err != nil { + return 0, E.Cause(err, "read response") + } + if response.StatusCode != 200 { + return 0, E.New("v2ray-http: unexpected status: ", response.Status) + } + if cacheLen := reader.Buffered(); cacheLen > 0 { + c.responseCache = buf.NewSize(cacheLen) + _, err = c.responseCache.ReadFullFrom(reader, cacheLen) + if err != nil { + c.responseCache.Release() + return 0, E.Cause(err, "read cache") + } + } + c.responseRead = true + } + if c.responseCache != nil { + n, err = c.responseCache.Read(b) + if err == io.EOF { + c.responseCache.Release() + c.responseCache = nil + } + if n > 0 { + return n, nil + } + } + return c.Conn.Read(b) +} + +func (c *HTTPConn) Write(b []byte) (int, error) { + if !c.requestWritten { + err := c.writeRequest(b) + if err != nil { + return 0, E.Cause(err, "write request") + } + c.requestWritten = true + return len(b), nil + } + return c.Conn.Write(b) +} + +func (c *HTTPConn) writeRequest(payload []byte) error { + writer := bufio.NewBufferedWriter(c.Conn, buf.New()) + const CRLF = "\r\n" + _, err := writer.Write([]byte(F.ToString(c.request.Method, " ", c.request.URL.RequestURI(), " HTTP/1.1", CRLF))) + if err != nil { + return err + } + for key, value := range c.request.Header { + _, err = writer.Write([]byte(F.ToString(key, ": ", strings.Join(value, ", "), CRLF))) + if err != nil { + return err + } + } + _, err = writer.Write([]byte(CRLF)) + if err != nil { + return err + } + _, err = writer.Write(payload) + if err != nil { + return err + } + err = writer.Fallthrough() + if err != nil { + return err + } + return nil +} + +func (c *HTTPConn) ReaderReplaceable() bool { + return c.responseRead +} + +func (c *HTTPConn) WriterReplaceable() bool { + return c.requestWritten +} + +func (c *HTTPConn) NeedHandshake() bool { + return !c.requestWritten +} + +func (c *HTTPConn) Upstream() any { + return c.Conn +} + +type HTTP2Conn struct { + reader io.Reader + writer io.Writer + create chan struct{} + err error +} + +func NewHTTPConn(reader io.Reader, writer io.Writer) HTTP2Conn { + return HTTP2Conn{ + reader: reader, + writer: writer, + } +} + +func NewLateHTTPConn(writer io.Writer) *HTTP2Conn { + return &HTTP2Conn{ + create: make(chan struct{}), + writer: writer, + } +} + +func (c *HTTP2Conn) Setup(reader io.Reader, err error) { + c.reader = reader + c.err = err + close(c.create) +} + +func (c *HTTP2Conn) Read(b []byte) (n int, err error) { + if c.reader == nil { + <-c.create + if c.err != nil { + return 0, c.err + } + } + n, err = c.reader.Read(b) + return n, baderror.WrapH2(err) +} + +func (c *HTTP2Conn) Write(b []byte) (n int, err error) { + n, err = c.writer.Write(b) + return n, baderror.WrapH2(err) +} + +func (c *HTTP2Conn) Close() error { + return common.Close(c.reader, c.writer) +} + +func (c *HTTP2Conn) LocalAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *HTTP2Conn) RemoteAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *HTTP2Conn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *HTTP2Conn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *HTTP2Conn) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *HTTP2Conn) NeedAdditionalReadDeadline() bool { + return true +} + +type ServerHTTPConn struct { + HTTP2Conn + Flusher http.Flusher +} + +func (c *ServerHTTPConn) Write(b []byte) (n int, err error) { + n, err = c.writer.Write(b) + if err == nil { + c.Flusher.Flush() + } + return +} + +type HTTP2ConnWrapper struct { + N.ExtendedConn + access sync.Mutex + closed bool +} + +func NewHTTP2Wrapper(conn net.Conn) *HTTP2ConnWrapper { + return &HTTP2ConnWrapper{ + ExtendedConn: bufio.NewExtendedConn(conn), + } +} + +func (w *HTTP2ConnWrapper) Write(p []byte) (n int, err error) { + w.access.Lock() + defer w.access.Unlock() + if w.closed { + return 0, net.ErrClosed + } + return w.ExtendedConn.Write(p) +} + +func (w *HTTP2ConnWrapper) WriteBuffer(buffer *buf.Buffer) error { + w.access.Lock() + defer w.access.Unlock() + if w.closed { + return net.ErrClosed + } + return w.ExtendedConn.WriteBuffer(buffer) +} + +func (w *HTTP2ConnWrapper) CloseWrapper() { + w.access.Lock() + defer w.access.Unlock() + w.closed = true +} + +func (w *HTTP2ConnWrapper) Close() error { + w.CloseWrapper() + return w.ExtendedConn.Close() +} + +func (w *HTTP2ConnWrapper) Upstream() any { + return w.ExtendedConn +} + +func DupContext(ctx context.Context) context.Context { + id, loaded := log.IDFromContext(ctx) + if !loaded { + return context.Background() + } + return log.ContextWithID(context.Background(), id) +} diff --git a/transport/v2rayhttp/force_close.go b/transport/v2rayhttp/force_close.go new file mode 100644 index 00000000..d574a510 --- /dev/null +++ b/transport/v2rayhttp/force_close.go @@ -0,0 +1,47 @@ +package v2rayhttp + +import ( + "net/http" + "reflect" + "sync" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/net/http2" +) + +type clientConnPool struct { + t *http2.Transport + mu sync.Mutex + conns map[string][]*http2.ClientConn // key is host:port +} + +type efaceWords struct { + typ unsafe.Pointer + data unsafe.Pointer +} + +func ResetTransport(rawTransport http.RoundTripper) http.RoundTripper { + switch transport := rawTransport.(type) { + case *http.Transport: + transport.CloseIdleConnections() + return transport.Clone() + case *http2.Transport: + connPool := transportConnPool(transport) + p := (*clientConnPool)((*efaceWords)(unsafe.Pointer(&connPool)).data) + p.mu.Lock() + defer p.mu.Unlock() + for _, vv := range p.conns { + for _, cc := range vv { + cc.Close() + } + } + return transport + default: + panic(E.New("unknown transport type: ", reflect.TypeOf(transport))) + } +} + +//go:linkname transportConnPool golang.org/x/net/http2.(*Transport).connPool +func transportConnPool(t *http2.Transport) http2.ClientConnPool diff --git a/transport/v2rayhttp/pool.go b/transport/v2rayhttp/pool.go new file mode 100644 index 00000000..7e9ba64f --- /dev/null +++ b/transport/v2rayhttp/pool.go @@ -0,0 +1,13 @@ +package v2rayhttp + +import "net/http" + +type ConnectionPool interface { + CloseIdleConnections() +} + +func CloseIdleConnections(transport http.RoundTripper) { + if connectionPool, ok := transport.(ConnectionPool); ok { + connectionPool.CloseIdleConnections() + } +} diff --git a/transport/v2rayhttp/server.go b/transport/v2rayhttp/server.go new file mode 100644 index 00000000..282c7c23 --- /dev/null +++ b/transport/v2rayhttp/server.go @@ -0,0 +1,183 @@ +package v2rayhttp + +import ( + "context" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + sHttp "github.com/sagernet/sing/protocol/http" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + tlsConfig tls.ServerConfig + handler adapter.V2RayServerTransportHandler + httpServer *http.Server + h2Server *http2.Server + h2cHandler http.Handler + host []string + path string + method string + headers http.Header +} + +func NewServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayHTTPOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (*Server, error) { + server := &Server{ + ctx: ctx, + tlsConfig: tlsConfig, + logger: logger, + handler: handler, + h2Server: &http2.Server{ + IdleTimeout: time.Duration(options.IdleTimeout), + }, + host: options.Host, + path: options.Path, + method: options.Method, + headers: options.Headers.Build(), + } + if !strings.HasPrefix(server.path, "/") { + server.path = "/" + server.path + } + server.httpServer = &http.Server{ + Handler: server, + ReadHeaderTimeout: C.TCPTimeout, + MaxHeaderBytes: http.DefaultMaxHeaderBytes, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return log.ContextWithNewID(ctx) + }, + } + server.h2cHandler = h2c.NewHandler(server, server.h2Server) + return server, nil +} + +func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if request.Method == "PRI" && len(request.Header) == 0 && request.URL.Path == "*" && request.Proto == "HTTP/2.0" { + s.h2cHandler.ServeHTTP(writer, request) + return + } + host := request.Host + if len(s.host) > 0 && !common.Contains(s.host, host) { + s.invalidRequest(writer, request, http.StatusBadRequest, E.New("bad host: ", host)) + return + } + if !strings.HasPrefix(request.URL.Path, s.path) { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad path: ", request.URL.Path)) + return + } + if s.method != "" && request.Method != s.method { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad method: ", request.Method)) + return + } + + writer.Header().Set("Cache-Control", "no-store") + + for key, values := range s.headers { + for _, value := range values { + writer.Header().Set(key, value) + } + } + + source := sHttp.SourceAddress(request) + if h, ok := writer.(http.Hijacker); ok { + var requestBody *buf.Buffer + if contentLength := int(request.ContentLength); contentLength > 0 { + requestBody = buf.NewSize(contentLength) + _, err := requestBody.ReadFullFrom(request.Body, contentLength) + if err != nil { + s.invalidRequest(writer, request, 0, E.Cause(err, "read request")) + return + } + } + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + conn, reader, err := h.Hijack() + if err != nil { + s.invalidRequest(writer, request, 0, E.Cause(err, "hijack conn")) + return + } + if cacheLen := reader.Reader.Buffered(); cacheLen > 0 { + cache := buf.NewSize(cacheLen) + _, err = cache.ReadFullFrom(reader.Reader, cacheLen) + if err != nil { + conn.Close() + s.invalidRequest(writer, request, 0, E.Cause(err, "read cache")) + return + } + conn = bufio.NewCachedConn(conn, cache) + } + if requestBody != nil { + conn = bufio.NewCachedConn(conn, requestBody) + } + s.handler.NewConnectionEx(DupContext(request.Context()), conn, source, M.Socksaddr{}, nil) + } else { + writer.WriteHeader(http.StatusOK) + flusher := writer.(http.Flusher) + flusher.Flush() + done := make(chan struct{}) + conn := NewHTTP2Wrapper(&ServerHTTPConn{ + NewHTTPConn(request.Body, writer), + flusher, + }) + s.handler.NewConnectionEx(request.Context(), conn, source, M.Socksaddr{}, N.OnceClose(func(it error) { + close(done) + })) + <-done + conn.CloseWrapper() + } +} + +func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { + if statusCode > 0 { + writer.WriteHeader(statusCode) + } + s.logger.ErrorContext(request.Context(), E.Cause(err, "process connection from ", request.RemoteAddr)) +} + +func (s *Server) Network() []string { + return []string{N.NetworkTCP} +} + +func (s *Server) Serve(listener net.Listener) error { + if s.tlsConfig != nil { + if len(s.tlsConfig.NextProtos()) == 0 { + s.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"}) + } else if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, s.tlsConfig.NextProtos()...)) + } + listener = aTLS.NewListener(listener, s.tlsConfig) + } + return s.httpServer.Serve(listener) +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + return os.ErrInvalid +} + +func (s *Server) Close() error { + return common.Close(common.PtrOrNil(s.httpServer)) +} diff --git a/transport/v2rayhttpupgrade/client.go b/transport/v2rayhttpupgrade/client.go new file mode 100644 index 00000000..f282d3f6 --- /dev/null +++ b/transport/v2rayhttpupgrade/client.go @@ -0,0 +1,115 @@ +package v2rayhttpupgrade + +import ( + std_bufio "bufio" + "context" + "net" + "net/http" + "net/url" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + sHTTP "github.com/sagernet/sing/protocol/http" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + dialer N.Dialer + serverAddr M.Socksaddr + requestURL url.URL + headers http.Header + host string +} + +func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayHTTPUpgradeOptions, tlsConfig tls.Config) (*Client, error) { + if tlsConfig != nil { + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{"http/1.1"}) + } + dialer = tls.NewDialer(dialer, tlsConfig) + } + var host string + if options.Host != "" { + host = options.Host + } else if tlsConfig != nil && tlsConfig.ServerName() != "" { + host = tlsConfig.ServerName() + } else { + host = serverAddr.String() + } + var requestURL url.URL + if tlsConfig == nil { + requestURL.Scheme = "http" + } else { + requestURL.Scheme = "https" + } + requestURL.Host = serverAddr.String() + requestURL.Path = options.Path + err := sHTTP.URLSetPath(&requestURL, options.Path) + if err != nil { + return nil, E.Cause(err, "parse path") + } + if !strings.HasPrefix(requestURL.Path, "/") { + requestURL.Path = "/" + requestURL.Path + } + headers := make(http.Header) + for key, value := range options.Headers { + headers[key] = value + } + return &Client{ + dialer: dialer, + serverAddr: serverAddr, + requestURL: requestURL, + headers: headers, + host: host, + }, nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + conn, err := c.dialer.DialContext(ctx, N.NetworkTCP, c.serverAddr) + if err != nil { + return nil, err + } + request := &http.Request{ + Method: http.MethodGet, + URL: &c.requestURL, + Header: c.headers.Clone(), + Host: c.host, + } + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + err = request.Write(conn) + if err != nil { + return nil, err + } + bufReader := std_bufio.NewReader(conn) + response, err := http.ReadResponse(bufReader, request) + if err != nil { + return nil, err + } + if response.StatusCode != 101 || + !strings.EqualFold(response.Header.Get("Connection"), "upgrade") || + !strings.EqualFold(response.Header.Get("Upgrade"), "websocket") { + return nil, E.New("v2ray-http-upgrade: unexpected status: ", response.Status) + } + if bufReader.Buffered() > 0 { + buffer := buf.NewSize(bufReader.Buffered()) + _, err = buffer.ReadFullFrom(bufReader, buffer.Len()) + if err != nil { + return nil, err + } + conn = bufio.NewCachedConn(conn, buffer) + } + return conn, nil +} + +func (c *Client) Close() error { + return nil +} diff --git a/transport/v2rayhttpupgrade/server.go b/transport/v2rayhttpupgrade/server.go new file mode 100644 index 00000000..338b7248 --- /dev/null +++ b/transport/v2rayhttpupgrade/server.go @@ -0,0 +1,145 @@ +package v2rayhttpupgrade + +import ( + "context" + "net" + "net/http" + "os" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + sHttp "github.com/sagernet/sing/protocol/http" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + tlsConfig tls.ServerConfig + handler adapter.V2RayServerTransportHandler + httpServer *http.Server + host string + path string + headers http.Header +} + +func NewServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayHTTPUpgradeOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (*Server, error) { + server := &Server{ + ctx: ctx, + logger: logger, + tlsConfig: tlsConfig, + handler: handler, + host: options.Host, + path: options.Path, + headers: options.Headers.Build(), + } + if !strings.HasPrefix(server.path, "/") { + server.path = "/" + server.path + } + server.httpServer = &http.Server{ + Handler: server, + ReadHeaderTimeout: C.TCPTimeout, + MaxHeaderBytes: http.DefaultMaxHeaderBytes, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return log.ContextWithNewID(ctx) + }, + TLSNextProto: make(map[string]func(*http.Server, *tls.STDConn, http.Handler)), + } + return server, nil +} + +type httpFlusher interface { + FlushError() error +} + +func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + host := request.Host + if len(s.host) > 0 && host != s.host { + s.invalidRequest(writer, request, http.StatusBadRequest, E.New("bad host: ", host)) + return + } + if request.URL.Path != s.path { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad path: ", request.URL.Path)) + return + } + if request.Method != http.MethodGet { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad method: ", request.Method)) + return + } + if !strings.EqualFold(request.Header.Get("Connection"), "upgrade") { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("not a upgrade request")) + return + } + if !strings.EqualFold(request.Header.Get("Upgrade"), "websocket") { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("not a websocket request")) + return + } + if request.Header.Get("Sec-WebSocket-Key") != "" { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("real websocket request received")) + return + } + writer.Header().Set("Connection", "upgrade") + writer.Header().Set("Upgrade", "websocket") + writer.WriteHeader(http.StatusSwitchingProtocols) + if flusher, isFlusher := writer.(httpFlusher); isFlusher { + err := flusher.FlushError() + if err != nil { + s.invalidRequest(writer, request, http.StatusInternalServerError, E.New("flush response")) + } + } + hijacker, canHijack := writer.(http.Hijacker) + if !canHijack { + s.invalidRequest(writer, request, http.StatusInternalServerError, E.New("invalid connection, maybe HTTP/2")) + return + } + conn, _, err := hijacker.Hijack() + if err != nil { + s.invalidRequest(writer, request, http.StatusInternalServerError, E.Cause(err, "hijack failed")) + return + } + s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) +} + +func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { + if statusCode > 0 { + writer.WriteHeader(statusCode) + } + s.logger.ErrorContext(request.Context(), E.Cause(err, "process connection from ", request.RemoteAddr)) +} + +func (s *Server) Network() []string { + return []string{N.NetworkTCP} +} + +func (s *Server) Serve(listener net.Listener) error { + if s.tlsConfig != nil { + if len(s.tlsConfig.NextProtos()) == 0 { + s.tlsConfig.SetNextProtos([]string{"http/1.1"}) + } + listener = aTLS.NewListener(listener, s.tlsConfig) + } + return s.httpServer.Serve(listener) +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + return os.ErrInvalid +} + +func (s *Server) Close() error { + return common.Close(common.PtrOrNil(s.httpServer)) +} diff --git a/transport/v2rayquic/client.go b/transport/v2rayquic/client.go new file mode 100644 index 00000000..3e0d8b81 --- /dev/null +++ b/transport/v2rayquic/client.go @@ -0,0 +1,110 @@ +//go:build with_quic + +package v2rayquic + +import ( + "context" + "net" + "sync" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + ctx context.Context + dialer N.Dialer + serverAddr M.Socksaddr + tlsConfig tls.Config + quicConfig *quic.Config + connAccess sync.Mutex + conn common.TypedValue[*quic.Conn] + rawConn net.Conn +} + +func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + quicConfig := &quic.Config{ + DisablePathMTUDiscovery: !C.IsLinux && !C.IsWindows, + } + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http3.NextProtoH3}) + } + return &Client{ + ctx: ctx, + dialer: dialer, + serverAddr: serverAddr, + tlsConfig: tlsConfig, + quicConfig: quicConfig, + }, nil +} + +func (c *Client) offer() (*quic.Conn, error) { + conn := c.conn.Load() + if conn != nil && !common.Done(conn.Context()) { + return conn, nil + } + c.connAccess.Lock() + defer c.connAccess.Unlock() + conn = c.conn.Load() + if conn != nil && !common.Done(conn.Context()) { + return conn, nil + } + conn, err := c.offerNew() + if err != nil { + return nil, err + } + return conn, nil +} + +func (c *Client) offerNew() (*quic.Conn, error) { + udpConn, err := c.dialer.DialContext(c.ctx, "udp", c.serverAddr) + if err != nil { + return nil, err + } + packetConn := bufio.NewUnbindPacketConn(udpConn) + quicConn, err := qtls.Dial(c.ctx, packetConn, udpConn.RemoteAddr(), c.tlsConfig, c.quicConfig) + if err != nil { + packetConn.Close() + return nil, err + } + c.conn.Store(quicConn) + c.rawConn = udpConn + return quicConn, nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + conn, err := c.offer() + if err != nil { + return nil, err + } + stream, err := conn.OpenStream() + if err != nil { + return nil, err + } + return &StreamWrapper{Conn: conn, Stream: stream}, nil +} + +func (c *Client) Close() error { + c.connAccess.Lock() + defer c.connAccess.Unlock() + conn := c.conn.Swap(nil) + if conn != nil { + conn.CloseWithError(0, "") + } + if c.rawConn != nil { + c.rawConn.Close() + } + c.rawConn = nil + return nil +} diff --git a/transport/v2rayquic/init.go b/transport/v2rayquic/init.go new file mode 100644 index 00000000..83c7fc3d --- /dev/null +++ b/transport/v2rayquic/init.go @@ -0,0 +1,9 @@ +//go:build with_quic + +package v2rayquic + +import "github.com/sagernet/sing-box/transport/v2ray" + +func init() { + v2ray.RegisterQUICConstructor(NewServer, NewClient) +} diff --git a/transport/v2rayquic/server.go b/transport/v2rayquic/server.go new file mode 100644 index 00000000..c50d92f0 --- /dev/null +++ b/transport/v2rayquic/server.go @@ -0,0 +1,99 @@ +//go:build with_quic + +package v2rayquic + +import ( + "context" + "net" + "os" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + tlsConfig tls.ServerConfig + quicConfig *quic.Config + handler adapter.V2RayServerTransportHandler + udpListener net.PacketConn + quicListener qtls.Listener +} + +func NewServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayQUICOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (adapter.V2RayServerTransport, error) { + quicConfig := &quic.Config{ + DisablePathMTUDiscovery: !C.IsLinux && !C.IsWindows, + } + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http3.NextProtoH3}) + } + server := &Server{ + ctx: ctx, + logger: logger, + tlsConfig: tlsConfig, + quicConfig: quicConfig, + handler: handler, + } + return server, nil +} + +func (s *Server) Network() []string { + return []string{N.NetworkUDP} +} + +func (s *Server) Serve(listener net.Listener) error { + return os.ErrInvalid +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + quicListener, err := qtls.Listen(listener, s.tlsConfig, s.quicConfig) + if err != nil { + return err + } + s.udpListener = listener + s.quicListener = quicListener + go s.acceptLoop() + return nil +} + +func (s *Server) acceptLoop() { + for { + conn, err := s.quicListener.Accept(s.ctx) + if err != nil { + return + } + go func() { + hErr := s.streamAcceptLoop(conn) + if hErr != nil && !E.IsClosedOrCanceled(hErr) { + s.logger.ErrorContext(conn.Context(), hErr) + } + }() + } +} + +func (s *Server) streamAcceptLoop(conn *quic.Conn) error { + for { + stream, err := conn.AcceptStream(s.ctx) + if err != nil { + return qtls.WrapError(err) + } + go s.handler.NewConnectionEx(conn.Context(), &StreamWrapper{Conn: conn, Stream: stream}, M.SocksaddrFromNet(conn.RemoteAddr()), M.Socksaddr{}, nil) + } +} + +func (s *Server) Close() error { + return common.Close(s.udpListener, s.quicListener) +} diff --git a/transport/v2rayquic/stream.go b/transport/v2rayquic/stream.go new file mode 100644 index 00000000..aad62afb --- /dev/null +++ b/transport/v2rayquic/stream.go @@ -0,0 +1,41 @@ +package v2rayquic + +import ( + "net" + + "github.com/sagernet/quic-go" + qtls "github.com/sagernet/sing-quic" +) + +type StreamWrapper struct { + Conn *quic.Conn + *quic.Stream +} + +func (s *StreamWrapper) Read(p []byte) (n int, err error) { + n, err = s.Stream.Read(p) + return n, qtls.WrapError(err) +} + +func (s *StreamWrapper) Write(p []byte) (n int, err error) { + n, err = s.Stream.Write(p) + return n, qtls.WrapError(err) +} + +func (s *StreamWrapper) LocalAddr() net.Addr { + return s.Conn.LocalAddr() +} + +func (s *StreamWrapper) RemoteAddr() net.Addr { + return s.Conn.RemoteAddr() +} + +func (s *StreamWrapper) Upstream() any { + return s.Stream +} + +func (s *StreamWrapper) Close() error { + s.CancelRead(0) + s.Stream.Close() + return nil +} diff --git a/transport/v2raywebsocket/client.go b/transport/v2raywebsocket/client.go new file mode 100644 index 00000000..e5630109 --- /dev/null +++ b/transport/v2raywebsocket/client.go @@ -0,0 +1,123 @@ +package v2raywebsocket + +import ( + "context" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/bufio/deadline" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + sHTTP "github.com/sagernet/sing/protocol/http" + "github.com/sagernet/ws" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + dialer N.Dialer + serverAddr M.Socksaddr + requestURL url.URL + headers http.Header + maxEarlyData uint32 + earlyDataHeaderName string +} + +func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayWebsocketOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { + if tlsConfig != nil { + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{"http/1.1"}) + } + dialer = tls.NewDialer(dialer, tlsConfig) + } + var requestURL url.URL + if tlsConfig == nil { + requestURL.Scheme = "ws" + } else { + requestURL.Scheme = "wss" + } + requestURL.Host = serverAddr.String() + requestURL.Path = options.Path + err := sHTTP.URLSetPath(&requestURL, options.Path) + if err != nil { + return nil, E.Cause(err, "parse path") + } + if !strings.HasPrefix(requestURL.Path, "/") { + requestURL.Path = "/" + requestURL.Path + } + headers := options.Headers.Build() + if host := headers.Get("Host"); host != "" { + headers.Del("Host") + requestURL.Host = host + } + if headers.Get("User-Agent") == "" { + headers.Set("User-Agent", "Go-http-client/1.1") + } + return &Client{ + dialer, + serverAddr, + requestURL, + headers, + options.MaxEarlyData, + options.EarlyDataHeaderName, + }, nil +} + +func (c *Client) dialContext(ctx context.Context, requestURL *url.URL, headers http.Header) (*WebsocketConn, error) { + conn, err := c.dialer.DialContext(ctx, N.NetworkTCP, c.serverAddr) + if err != nil { + return nil, err + } + var deadlineConn net.Conn + if deadline.NeedAdditionalReadDeadline(conn) { + deadlineConn = deadline.NewConn(conn) + } else { + deadlineConn = conn + } + deadlineConn.SetDeadline(time.Now().Add(C.TCPTimeout)) + var protocols []string + if protocolHeader := headers.Get("Sec-WebSocket-Protocol"); protocolHeader != "" { + protocols = []string{protocolHeader} + headers.Del("Sec-WebSocket-Protocol") + } + reader, _, err := ws.Dialer{Header: ws.HandshakeHeaderHTTP(headers), Protocols: protocols}.Upgrade(deadlineConn, requestURL) + deadlineConn.SetDeadline(time.Time{}) + if err != nil { + return nil, err + } + if reader != nil { + buffer := buf.NewSize(reader.Buffered()) + _, err = buffer.ReadFullFrom(reader, buffer.Len()) + if err != nil { + return nil, err + } + conn = bufio.NewCachedConn(conn, buffer) + } + return NewConn(conn, nil, ws.StateClientSide), nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + if c.maxEarlyData <= 0 { + conn, err := c.dialContext(ctx, &c.requestURL, c.headers) + if err != nil { + return nil, err + } + return conn, nil + } else { + return &EarlyWebsocketConn{Client: c, ctx: ctx, create: make(chan struct{})}, nil + } +} + +func (c *Client) Close() error { + return nil +} diff --git a/transport/v2raywebsocket/conn.go b/transport/v2raywebsocket/conn.go new file mode 100644 index 00000000..cb788fc9 --- /dev/null +++ b/transport/v2raywebsocket/conn.go @@ -0,0 +1,306 @@ +package v2raywebsocket + +import ( + "context" + "encoding/base64" + "errors" + "io" + "net" + "os" + "sync" + "sync/atomic" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/debug" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/ws" + "github.com/sagernet/ws/wsutil" +) + +type WebsocketConn struct { + net.Conn + *Writer + state ws.State + reader *wsutil.Reader + controlHandler wsutil.FrameHandlerFunc + remoteAddr net.Addr +} + +func NewConn(conn net.Conn, remoteAddr net.Addr, state ws.State) *WebsocketConn { + controlHandler := wsutil.ControlFrameHandler(conn, state) + return &WebsocketConn{ + Conn: conn, + state: state, + reader: &wsutil.Reader{ + Source: conn, + State: state, + SkipHeaderCheck: !debug.Enabled, + OnIntermediate: controlHandler, + }, + controlHandler: controlHandler, + remoteAddr: remoteAddr, + Writer: NewWriter(conn, state), + } +} + +func (c *WebsocketConn) Close() error { + c.Conn.SetWriteDeadline(time.Now().Add(C.TCPTimeout)) + frame := ws.NewCloseFrame(ws.NewCloseFrameBody( + ws.StatusNormalClosure, "", + )) + if c.state == ws.StateClientSide { + frame = ws.MaskFrameInPlace(frame) + } + ws.WriteFrame(c.Conn, frame) + c.Conn.Close() + return nil +} + +func (c *WebsocketConn) Read(b []byte) (n int, err error) { + var header ws.Header + for { + n, err = c.reader.Read(b) + if n > 0 { + err = nil + return + } + if !E.IsMulti(err, io.EOF, wsutil.ErrNoFrameAdvance) { + err = wrapWsError(err) + return + } + header, err = wrapWsError0(c.reader.NextFrame()) + if err != nil { + return + } + if header.OpCode.IsControl() { + if header.Length > 128 { + err = wsutil.ErrFrameTooLarge + return + } + err = wrapWsError(c.controlHandler(header, c.reader)) + if err != nil { + return + } + continue + } + if header.OpCode&ws.OpBinary == 0 { + err = wrapWsError(c.reader.Discard()) + if err != nil { + return + } + continue + } + } +} + +func (c *WebsocketConn) Write(p []byte) (n int, err error) { + err = wrapWsError(wsutil.WriteMessage(c.Conn, c.state, ws.OpBinary, p)) + if err != nil { + return + } + n = len(p) + return +} + +func (c *WebsocketConn) RemoteAddr() net.Addr { + if c.remoteAddr != nil { + return c.remoteAddr + } + return c.Conn.RemoteAddr() +} + +func (c *WebsocketConn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *WebsocketConn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *WebsocketConn) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *WebsocketConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *WebsocketConn) Upstream() any { + return c.Conn +} + +type EarlyWebsocketConn struct { + *Client + ctx context.Context + conn atomic.Pointer[WebsocketConn] + access sync.Mutex + create chan struct{} + err error +} + +func (c *EarlyWebsocketConn) Read(b []byte) (n int, err error) { + conn := c.conn.Load() + if conn == nil { + <-c.create + if c.err != nil { + return 0, c.err + } + conn = c.conn.Load() + } + return wrapWsError0(conn.Read(b)) +} + +func (c *EarlyWebsocketConn) writeRequest(content []byte) error { + var ( + earlyData []byte + lateData []byte + conn *WebsocketConn + err error + ) + if len(content) > int(c.maxEarlyData) { + earlyData = content[:c.maxEarlyData] + lateData = content[c.maxEarlyData:] + } else { + earlyData = content + } + if len(earlyData) > 0 { + earlyDataString := base64.RawURLEncoding.EncodeToString(earlyData) + if c.earlyDataHeaderName == "" { + requestURL := c.requestURL + requestURL.Path += earlyDataString + conn, err = c.dialContext(c.ctx, &requestURL, c.headers) + } else { + headers := c.headers.Clone() + headers.Set(c.earlyDataHeaderName, earlyDataString) + conn, err = c.dialContext(c.ctx, &c.requestURL, headers) + } + } else { + conn, err = c.dialContext(c.ctx, &c.requestURL, c.headers) + } + if err != nil { + return err + } + if len(lateData) > 0 { + _, err = conn.Write(lateData) + if err != nil { + return err + } + } + c.conn.Store(conn) + return nil +} + +func (c *EarlyWebsocketConn) Write(b []byte) (n int, err error) { + conn := c.conn.Load() + if conn != nil { + return wrapWsError0(conn.Write(b)) + } + c.access.Lock() + defer c.access.Unlock() + conn = c.conn.Load() + if c.err != nil { + return 0, c.err + } + if conn != nil { + return wrapWsError0(conn.Write(b)) + } + err = c.writeRequest(b) + c.err = err + close(c.create) + if err != nil { + return + } + return len(b), nil +} + +func (c *EarlyWebsocketConn) WriteBuffer(buffer *buf.Buffer) error { + conn := c.conn.Load() + if conn != nil { + return wrapWsError(conn.WriteBuffer(buffer)) + } + c.access.Lock() + defer c.access.Unlock() + if c.err != nil { + return c.err + } + conn = c.conn.Load() + if conn != nil { + return wrapWsError(conn.WriteBuffer(buffer)) + } + err := c.writeRequest(buffer.Bytes()) + c.err = err + close(c.create) + return err +} + +func (c *EarlyWebsocketConn) Close() error { + conn := c.conn.Load() + if conn == nil { + return nil + } + return conn.Close() +} + +func (c *EarlyWebsocketConn) LocalAddr() net.Addr { + conn := c.conn.Load() + if conn == nil { + return M.Socksaddr{} + } + return conn.LocalAddr() +} + +func (c *EarlyWebsocketConn) RemoteAddr() net.Addr { + conn := c.conn.Load() + if conn == nil { + return M.Socksaddr{} + } + return conn.RemoteAddr() +} + +func (c *EarlyWebsocketConn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *EarlyWebsocketConn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *EarlyWebsocketConn) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *EarlyWebsocketConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *EarlyWebsocketConn) Upstream() any { + return common.PtrOrNil(c.conn.Load()) +} + +func (c *EarlyWebsocketConn) LazyHeadroom() bool { + return c.conn.Load() == nil +} + +func wrapWsError(err error) error { + if err == nil { + return nil + } + var closedErr wsutil.ClosedError + if errors.As(err, &closedErr) { + if closedErr.Code == ws.StatusNormalClosure || closedErr.Code == ws.StatusNoStatusRcvd { + err = io.EOF + } + } + return err +} + +func wrapWsError0[T any](value T, err error) (T, error) { + if err == nil { + return value, nil + } + return value, wrapWsError(err) +} diff --git a/transport/v2raywebsocket/server.go b/transport/v2raywebsocket/server.go new file mode 100644 index 00000000..b54d760a --- /dev/null +++ b/transport/v2raywebsocket/server.go @@ -0,0 +1,145 @@ +package v2raywebsocket + +import ( + "context" + "encoding/base64" + "net" + "net/http" + "os" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + sHttp "github.com/sagernet/sing/protocol/http" + "github.com/sagernet/ws" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + tlsConfig tls.ServerConfig + handler adapter.V2RayServerTransportHandler + httpServer *http.Server + path string + maxEarlyData uint32 + earlyDataHeaderName string + upgrader ws.HTTPUpgrader +} + +func NewServer(ctx context.Context, logger logger.ContextLogger, options option.V2RayWebsocketOptions, tlsConfig tls.ServerConfig, handler adapter.V2RayServerTransportHandler) (*Server, error) { + server := &Server{ + ctx: ctx, + logger: logger, + tlsConfig: tlsConfig, + handler: handler, + path: options.Path, + maxEarlyData: options.MaxEarlyData, + earlyDataHeaderName: options.EarlyDataHeaderName, + upgrader: ws.HTTPUpgrader{ + Timeout: C.TCPTimeout, + Header: options.Headers.Build(), + }, + } + if !strings.HasPrefix(server.path, "/") { + server.path = "/" + server.path + } + server.httpServer = &http.Server{ + Handler: server, + ReadHeaderTimeout: C.TCPTimeout, + MaxHeaderBytes: http.DefaultMaxHeaderBytes, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return log.ContextWithNewID(ctx) + }, + } + return server, nil +} + +func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if s.maxEarlyData == 0 || s.earlyDataHeaderName != "" { + if request.URL.Path != s.path { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad path: ", request.URL.Path)) + return + } + } + var ( + earlyData []byte + err error + conn net.Conn + ) + if s.earlyDataHeaderName == "" { + if strings.HasPrefix(request.URL.RequestURI(), s.path) { + earlyDataStr := request.URL.RequestURI()[len(s.path):] + earlyData, err = base64.RawURLEncoding.DecodeString(earlyDataStr) + } else { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad path: ", request.URL.Path)) + return + } + } else { + if request.URL.Path != s.path { + s.invalidRequest(writer, request, http.StatusNotFound, E.New("bad path: ", request.URL.Path)) + return + } + earlyDataStr := request.Header.Get(s.earlyDataHeaderName) + if earlyDataStr != "" { + earlyData, err = base64.RawURLEncoding.DecodeString(earlyDataStr) + } + } + if err != nil { + s.invalidRequest(writer, request, http.StatusBadRequest, E.Cause(err, "decode early data")) + return + } + wsConn, _, _, err := ws.UpgradeHTTP(request, writer) + if err != nil { + s.invalidRequest(writer, request, 0, E.Cause(err, "upgrade websocket connection")) + return + } + source := sHttp.SourceAddress(request) + conn = NewConn(wsConn, source, ws.StateServerSide) + if len(earlyData) > 0 { + conn = bufio.NewCachedConn(conn, buf.As(earlyData)) + } + s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, source, M.Socksaddr{}, nil) +} + +func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { + if statusCode > 0 { + writer.WriteHeader(statusCode) + } + s.logger.ErrorContext(request.Context(), E.Cause(err, "process connection from ", request.RemoteAddr)) +} + +func (s *Server) Network() []string { + return []string{N.NetworkTCP} +} + +func (s *Server) Serve(listener net.Listener) error { + if s.tlsConfig != nil { + listener = aTLS.NewListener(listener, s.tlsConfig) + } + return s.httpServer.Serve(listener) +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + return os.ErrInvalid +} + +func (s *Server) Close() error { + return common.Close(common.PtrOrNil(s.httpServer)) +} diff --git a/transport/v2raywebsocket/writer.go b/transport/v2raywebsocket/writer.go new file mode 100644 index 00000000..cd5a5d6a --- /dev/null +++ b/transport/v2raywebsocket/writer.go @@ -0,0 +1,74 @@ +package v2raywebsocket + +import ( + "encoding/binary" + "io" + "math/rand" + + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/ws" +) + +type Writer struct { + writer N.ExtendedWriter + isServer bool +} + +func NewWriter(writer io.Writer, state ws.State) *Writer { + return &Writer{ + bufio.NewExtendedWriter(writer), + state == ws.StateServerSide, + } +} + +func (w *Writer) WriteBuffer(buffer *buf.Buffer) error { + var payloadBitLength int + dataLen := buffer.Len() + data := buffer.Bytes() + if dataLen < 126 { + payloadBitLength = 1 + } else if dataLen < 65536 { + payloadBitLength = 3 + } else { + payloadBitLength = 9 + } + + var headerLen int + headerLen += 1 // FIN / RSV / OPCODE + headerLen += payloadBitLength + if !w.isServer { + headerLen += 4 // MASK KEY + } + + header := buffer.ExtendHeader(headerLen) + header[0] = byte(ws.OpBinary) | 0x80 + if w.isServer { + header[1] = 0 + } else { + header[1] = 1 << 7 + } + + if dataLen < 126 { + header[1] |= byte(dataLen) + } else if dataLen < 65536 { + header[1] |= 126 + binary.BigEndian.PutUint16(header[2:], uint16(dataLen)) + } else { + header[1] |= 127 + binary.BigEndian.PutUint64(header[2:], uint64(dataLen)) + } + + if !w.isServer { + maskKey := rand.Uint32() + binary.BigEndian.PutUint32(header[1+payloadBitLength:], maskKey) + ws.Cipher(data, [4]byte(header[1+payloadBitLength:]), 0) + } + + return wrapWsError(w.writer.WriteBuffer(buffer)) +} + +func (w *Writer) FrontHeadroom() int { + return 14 +} diff --git a/transport/wireguard/client_bind.go b/transport/wireguard/client_bind.go new file mode 100644 index 00000000..54b7be86 --- /dev/null +++ b/transport/wireguard/client_bind.go @@ -0,0 +1,262 @@ +package wireguard + +import ( + "context" + "net" + "net/netip" + "sync" + "time" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" + "github.com/sagernet/wireguard-go/conn" +) + +var _ conn.Bind = (*ClientBind)(nil) + +type ClientBind struct { + ctx context.Context + logger logger.Logger + pauseManager pause.Manager + bindCtx context.Context + bindDone context.CancelFunc + dialer N.Dialer + reservedForEndpoint map[netip.AddrPort][3]uint8 + connAccess sync.Mutex + conn *wireConn + done chan struct{} + isConnect bool + connectAddr netip.AddrPort + reserved [3]uint8 +} + +func NewClientBind(ctx context.Context, logger logger.Logger, dialer N.Dialer, isConnect bool, connectAddr netip.AddrPort, reserved [3]uint8) *ClientBind { + return &ClientBind{ + ctx: ctx, + logger: logger, + pauseManager: service.FromContext[pause.Manager](ctx), + dialer: dialer, + reservedForEndpoint: make(map[netip.AddrPort][3]uint8), + done: make(chan struct{}), + isConnect: isConnect, + connectAddr: connectAddr, + reserved: reserved, + } +} + +func (c *ClientBind) connect() (*wireConn, error) { + serverConn := c.conn + if serverConn != nil { + select { + case <-serverConn.done: + serverConn = nil + default: + return serverConn, nil + } + } + c.connAccess.Lock() + defer c.connAccess.Unlock() + select { + case <-c.done: + return nil, net.ErrClosed + default: + } + serverConn = c.conn + if serverConn != nil { + select { + case <-serverConn.done: + serverConn = nil + default: + return serverConn, nil + } + } + if c.isConnect { + udpConn, err := c.dialer.DialContext(c.bindCtx, N.NetworkUDP, M.SocksaddrFromNetIP(c.connectAddr)) + if err != nil { + return nil, err + } + c.conn = &wireConn{ + PacketConn: bufio.NewUnbindPacketConn(udpConn), + done: make(chan struct{}), + } + } else { + udpConn, err := c.dialer.ListenPacket(c.bindCtx, M.Socksaddr{Addr: netip.IPv4Unspecified()}) + if err != nil { + return nil, err + } + c.conn = &wireConn{ + PacketConn: bufio.NewPacketConn(udpConn), + done: make(chan struct{}), + } + } + return c.conn, nil +} + +func (c *ClientBind) Open(port uint16) (fns []conn.ReceiveFunc, actualPort uint16, err error) { + select { + case <-c.done: + c.done = make(chan struct{}) + default: + } + c.bindCtx, c.bindDone = context.WithCancel(c.ctx) + return []conn.ReceiveFunc{c.receive}, 0, nil +} + +func (c *ClientBind) receive(packets [][]byte, sizes []int, eps []conn.Endpoint) (count int, err error) { + udpConn, err := c.connect() + if err != nil { + select { + case <-c.done: + return + default: + } + c.logger.Error(E.Cause(err, "connect to server")) + err = nil + c.pauseManager.WaitActive() + time.Sleep(time.Second) + return + } + n, addr, err := udpConn.ReadFrom(packets[0]) + if err != nil { + udpConn.Close() + select { + case <-c.done: + default: + c.logger.Error(E.Cause(err, "read packet")) + err = nil + } + return + } + sizes[0] = n + if n > 3 { + b := packets[0] + common.ClearArray(b[1:4]) + } + eps[0] = remoteEndpoint(M.SocksaddrFromNet(addr).Unwrap().AddrPort()) + count = 1 + return +} + +func (c *ClientBind) Close() error { + select { + case <-c.done: + default: + close(c.done) + } + if c.bindDone != nil { + c.bindDone() + } + c.connAccess.Lock() + defer c.connAccess.Unlock() + common.Close(common.PtrOrNil(c.conn)) + return nil +} + +func (c *ClientBind) SetMark(mark uint32) error { + return nil +} + +func (c *ClientBind) Send(bufs [][]byte, ep conn.Endpoint, offset int) error { + udpConn, err := c.connect() + if err != nil { + c.pauseManager.WaitActive() + time.Sleep(time.Second) + return err + } + destination := netip.AddrPort(ep.(remoteEndpoint)) + for _, buf := range bufs { + if offset > 0 { + buf = buf[offset:] + } + if len(buf) > 3 { + reserved, loaded := c.reservedForEndpoint[destination] + if !loaded { + reserved = c.reserved + } + copy(buf[1:4], reserved[:]) + } + _, err = udpConn.WriteToUDPAddrPort(buf, destination) + if err != nil { + udpConn.Close() + return err + } + } + return nil +} + +func (c *ClientBind) ParseEndpoint(s string) (conn.Endpoint, error) { + ap, err := netip.ParseAddrPort(s) + if err != nil { + return nil, err + } + return remoteEndpoint(ap), nil +} + +func (c *ClientBind) BatchSize() int { + return 1 +} + +func (c *ClientBind) SetReservedForEndpoint(destination netip.AddrPort, reserved [3]byte) { + c.reservedForEndpoint[destination] = reserved +} + +type wireConn struct { + net.PacketConn + conn net.Conn + access sync.Mutex + done chan struct{} +} + +func (w *wireConn) WriteToUDPAddrPort(b []byte, addr netip.AddrPort) (int, error) { + if w.conn != nil { + return w.conn.Write(b) + } + return w.PacketConn.WriteTo(b, M.SocksaddrFromNetIP(addr).UDPAddr()) +} + +func (w *wireConn) Close() error { + w.access.Lock() + defer w.access.Unlock() + select { + case <-w.done: + return net.ErrClosed + default: + } + w.PacketConn.Close() + close(w.done) + return nil +} + +var _ conn.Endpoint = (*remoteEndpoint)(nil) + +type remoteEndpoint netip.AddrPort + +func (e remoteEndpoint) ClearSrc() { +} + +func (e remoteEndpoint) SrcToString() string { + return "" +} + +func (e remoteEndpoint) DstToString() string { + return (netip.AddrPort)(e).String() +} + +func (e remoteEndpoint) DstToBytes() []byte { + b, _ := (netip.AddrPort)(e).MarshalBinary() + return b +} + +func (e remoteEndpoint) DstIP() netip.Addr { + return (netip.AddrPort)(e).Addr() +} + +func (e remoteEndpoint) SrcIP() netip.Addr { + return netip.Addr{} +} diff --git a/transport/wireguard/device.go b/transport/wireguard/device.go new file mode 100644 index 00000000..4dd615c5 --- /dev/null +++ b/transport/wireguard/device.go @@ -0,0 +1,51 @@ +package wireguard + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/wireguard-go/device" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type Device interface { + wgTun.Device + N.Dialer + Start() error + SetDevice(device *device.Device) + Inet4Address() netip.Addr + Inet6Address() netip.Addr +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + AllowedAddress []netip.Prefix +} + +func NewDevice(options DeviceOptions) (Device, error) { + if !options.System { + return newStackDevice(options) + } else if !tun.WithGVisor { + return newSystemDevice(options) + } else { + return newSystemStackDevice(options) + } +} + +type NatDevice interface { + Device + CreateDestination(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) +} diff --git a/transport/wireguard/device_nat.go b/transport/wireguard/device_nat.go new file mode 100644 index 00000000..d214b737 --- /dev/null +++ b/transport/wireguard/device_nat.go @@ -0,0 +1,103 @@ +package wireguard + +import ( + "context" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/ping" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/logger" +) + +var _ Device = (*natDeviceWrapper)(nil) + +type natDeviceWrapper struct { + Device + ctx context.Context + logger logger.ContextLogger + packetOutbound chan *buf.Buffer + rewriter *ping.SourceRewriter + buffer [][]byte +} + +func NewNATDevice(ctx context.Context, logger logger.ContextLogger, upstream Device) NatDevice { + wrapper := &natDeviceWrapper{ + Device: upstream, + ctx: ctx, + logger: logger, + packetOutbound: make(chan *buf.Buffer, 256), + rewriter: ping.NewSourceRewriter(ctx, logger, upstream.Inet4Address(), upstream.Inet6Address()), + } + return wrapper +} + +func (d *natDeviceWrapper) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) { + select { + case packet := <-d.packetOutbound: + defer packet.Release() + sizes[0] = copy(bufs[0][offset:], packet.Bytes()) + return 1, nil + default: + } + return d.Device.Read(bufs, sizes, offset) +} + +func (d *natDeviceWrapper) Write(bufs [][]byte, offset int) (int, error) { + for _, buffer := range bufs { + handled, err := d.rewriter.WriteBack(buffer[offset:]) + if handled { + if err != nil { + return 0, err + } + } else { + d.buffer = append(d.buffer, buffer) + } + } + if len(d.buffer) > 0 { + _, err := d.Device.Write(d.buffer, offset) + if err != nil { + return 0, err + } + d.buffer = d.buffer[:0] + } + return 0, nil +} + +func (d *natDeviceWrapper) CreateDestination(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + ctx := log.ContextWithNewID(d.ctx) + session := tun.DirectRouteSession{ + Source: metadata.Source.Addr, + Destination: metadata.Destination.Addr, + } + d.rewriter.CreateSession(session, routeContext) + d.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString()) + return &natDestination{device: d, session: session}, nil +} + +var _ tun.DirectRouteDestination = (*natDestination)(nil) + +type natDestination struct { + device *natDeviceWrapper + session tun.DirectRouteSession + closed atomic.Bool +} + +func (d *natDestination) WritePacket(buffer *buf.Buffer) error { + d.device.rewriter.RewritePacket(buffer.Bytes()) + d.device.packetOutbound <- buffer + return nil +} + +func (d *natDestination) Close() error { + d.closed.Store(true) + d.device.rewriter.DeleteSession(d.session) + return nil +} + +func (d *natDestination) IsClosed() bool { + return d.closed.Load() +} diff --git a/transport/wireguard/device_stack.go b/transport/wireguard/device_stack.go new file mode 100644 index 00000000..a190baba --- /dev/null +++ b/transport/wireguard/device_stack.go @@ -0,0 +1,342 @@ +//go:build with_gvisor + +package wireguard + +import ( + "context" + "net" + "net/netip" + "os" + "time" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/ping" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/wireguard-go/device" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +var _ NatDevice = (*stackDevice)(nil) + +type stackDevice struct { + ctx context.Context + logger log.ContextLogger + stack *stack.Stack + mtu uint32 + events chan wgTun.Event + outbound chan *stack.PacketBuffer + packetOutbound chan *buf.Buffer + done chan struct{} + dispatcher stack.NetworkDispatcher + inet4Address netip.Addr + inet6Address netip.Addr +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + tunDevice := &stackDevice{ + ctx: options.Context, + logger: options.Logger, + mtu: options.MTU, + events: make(chan wgTun.Event, 1), + outbound: make(chan *stack.PacketBuffer, 256), + packetOutbound: make(chan *buf.Buffer, 256), + done: make(chan struct{}), + } + ipStack, err := tun.NewGVisorStackWithOptions((*wireEndpoint)(tunDevice), stack.NICOptions{}, true) + if err != nil { + return nil, err + } + var ( + inet4Address netip.Addr + inet6Address netip.Addr + ) + for _, prefix := range options.Address { + addr := tun.AddressFromAddr(prefix.Addr()) + protoAddr := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: addr, + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + inet4Address = prefix.Addr() + tunDevice.inet4Address = inet4Address + protoAddr.Protocol = ipv4.ProtocolNumber + } else { + inet6Address = prefix.Addr() + tunDevice.inet6Address = inet6Address + protoAddr.Protocol = ipv6.ProtocolNumber + } + gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, protoAddr, stack.AddressProperties{}) + if gErr != nil { + return nil, E.New("parse local address ", protoAddr.AddressWithPrefix, ": ", gErr.String()) + } + } + tunDevice.stack = ipStack + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout) + icmpForwarder.SetLocalAddresses(inet4Address, inet6Address) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + } + return tunDevice, nil +} + +func (w *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + addr := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !w.inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + if !w.inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + tcpConn, err := DialTCPWithBind(ctx, w.stack, bind, addr, networkProtocol) + if err != nil { + return nil, err + } + return tcpConn, nil + case N.NetworkUDP: + udpConn, err := gonet.DialUDP(w.stack, &bind, &addr, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } + udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil +} + +func (w *stackDevice) Inet4Address() netip.Addr { + return w.inet4Address +} + +func (w *stackDevice) Inet6Address() netip.Addr { + return w.inet6Address +} + +func (w *stackDevice) SetDevice(device *device.Device) { +} + +func (w *stackDevice) Start() error { + w.events <- wgTun.EventUp + return nil +} + +func (w *stackDevice) File() *os.File { + return nil +} + +func (w *stackDevice) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + select { + case packet, ok := <-w.outbound: + if !ok { + return 0, os.ErrClosed + } + defer packet.DecRef() + var copyN int + /*rangeIterate(packet.Data().AsRange(), func(view *buffer.View) { + copyN += copy(bufs[0][offset+copyN:], view.AsSlice()) + })*/ + for _, view := range packet.AsSlices() { + copyN += copy(bufs[0][offset+copyN:], view) + } + sizes[0] = copyN + return 1, nil + case packet := <-w.packetOutbound: + defer packet.Release() + sizes[0] = copy(bufs[0][offset:], packet.Bytes()) + return 1, nil + case <-w.done: + return 0, os.ErrClosed + } +} + +func (w *stackDevice) Write(bufs [][]byte, offset int) (count int, err error) { + for _, b := range bufs { + b = b[offset:] + if len(b) == 0 { + continue + } + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(b) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + } + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(b), + }) + w.dispatcher.DeliverNetworkPacket(networkProtocol, packetBuffer) + packetBuffer.DecRef() + count++ + } + return +} + +func (w *stackDevice) Flush() error { + return nil +} + +func (w *stackDevice) MTU() (int, error) { + return int(w.mtu), nil +} + +func (w *stackDevice) Name() (string, error) { + return "sing-box", nil +} + +func (w *stackDevice) Events() <-chan wgTun.Event { + return w.events +} + +func (w *stackDevice) Close() error { + close(w.done) + close(w.events) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + return nil +} + +func (w *stackDevice) BatchSize() int { + return 1 +} + +func (w *stackDevice) CreateDestination(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + ctx := log.ContextWithNewID(w.ctx) + destination, err := ping.ConnectGVisor( + ctx, w.logger, + metadata.Source.Addr, metadata.Destination.Addr, + routeContext, + w.stack, + w.inet4Address, w.inet6Address, + timeout, + ) + if err != nil { + return nil, err + } + w.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString()) + return destination, nil +} + +var _ stack.LinkEndpoint = (*wireEndpoint)(nil) + +type wireEndpoint stackDevice + +func (ep *wireEndpoint) MTU() uint32 { + return ep.mtu +} + +func (ep *wireEndpoint) SetMTU(mtu uint32) { +} + +func (ep *wireEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (ep *wireEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (ep *wireEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (ep *wireEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (ep *wireEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + ep.dispatcher = dispatcher +} + +func (ep *wireEndpoint) IsAttached() bool { + return ep.dispatcher != nil +} + +func (ep *wireEndpoint) Wait() { +} + +func (ep *wireEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (ep *wireEndpoint) AddHeader(buffer *stack.PacketBuffer) { +} + +func (ep *wireEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool { + return true +} + +func (ep *wireEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + for _, packetBuffer := range list.AsSlice() { + packetBuffer.IncRef() + select { + case <-ep.done: + return 0, &tcpip.ErrClosedForSend{} + case ep.outbound <- packetBuffer: + } + } + return list.Len(), nil +} + +func (ep *wireEndpoint) Close() { +} + +func (ep *wireEndpoint) SetOnCloseAction(f func()) { +} diff --git a/transport/wireguard/device_stack_gonet.go b/transport/wireguard/device_stack_gonet.go new file mode 100644 index 00000000..bc695205 --- /dev/null +++ b/transport/wireguard/device_stack_gonet.go @@ -0,0 +1,79 @@ +//go:build with_gvisor + +package wireguard + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "time" + + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/waiter" + "github.com/sagernet/sing-tun" + M "github.com/sagernet/sing/common/metadata" +) + +func DialTCPWithBind(ctx context.Context, s *stack.Stack, localAddr, remoteAddr tcpip.FullAddress, network tcpip.NetworkProtocolNumber) (*gonet.TCPConn, error) { + // Create TCP endpoint, then connect. + var wq waiter.Queue + ep, err := s.NewEndpoint(tcp.ProtocolNumber, network, &wq) + if err != nil { + return nil, errors.New(err.String()) + } + + // Create wait queue entry that notifies a channel. + // + // We do this unconditionally as Connect will always return an error. + waitEntry, notifyCh := waiter.NewChannelEntry(waiter.WritableEvents) + wq.EventRegister(&waitEntry) + defer wq.EventUnregister(&waitEntry) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Bind before connect if requested. + if localAddr != (tcpip.FullAddress{}) { + if err = ep.Bind(localAddr); err != nil { + return nil, fmt.Errorf("ep.Bind(%+v) = %s", localAddr, err) + } + } + + err = ep.Connect(remoteAddr) + if _, ok := err.(*tcpip.ErrConnectStarted); ok { + select { + case <-ctx.Done(): + ep.Close() + return nil, ctx.Err() + case <-notifyCh: + } + + err = ep.LastError() + } + if err != nil { + ep.Close() + return nil, &net.OpError{ + Op: "connect", + Net: "tcp", + Addr: M.SocksaddrFromNetIP(netip.AddrPortFrom(tun.AddrFromAddress(remoteAddr.Addr), remoteAddr.Port)).TCPAddr(), + Err: errors.New(err.String()), + } + } + + // sing-box added: set keepalive + ep.SocketOptions().SetKeepAlive(true) + keepAliveIdle := tcpip.KeepaliveIdleOption(15 * time.Second) + ep.SetSockOpt(&keepAliveIdle) + keepAliveInterval := tcpip.KeepaliveIntervalOption(15 * time.Second) + ep.SetSockOpt(&keepAliveInterval) + + return gonet.NewTCPConn(&wq, ep), nil +} diff --git a/transport/wireguard/device_stack_stub.go b/transport/wireguard/device_stack_stub.go new file mode 100644 index 00000000..ea413559 --- /dev/null +++ b/transport/wireguard/device_stack_stub.go @@ -0,0 +1,13 @@ +//go:build !with_gvisor + +package wireguard + +import "github.com/sagernet/sing-tun" + +func newStackDevice(options DeviceOptions) (Device, error) { + return nil, tun.ErrGVisorNotIncluded +} + +func newSystemStackDevice(options DeviceOptions) (Device, error) { + return nil, tun.ErrGVisorNotIncluded +} diff --git a/transport/wireguard/device_system.go b/transport/wireguard/device_system.go new file mode 100644 index 00000000..dcf2959b --- /dev/null +++ b/transport/wireguard/device_system.go @@ -0,0 +1,189 @@ +package wireguard + +import ( + "context" + "errors" + "net" + "net/netip" + "os" + "runtime" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + "github.com/sagernet/wireguard-go/device" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +var _ Device = (*systemDevice)(nil) + +type systemDevice struct { + options DeviceOptions + dialer N.Dialer + device tun.Tun + batchDevice tun.LinuxTUN + events chan wgTun.Event + closeOnce sync.Once + inet4Address netip.Addr + inet6Address netip.Addr +} + +func newSystemDevice(options DeviceOptions) (*systemDevice, error) { + if options.Name == "" { + options.Name = tun.CalculateInterfaceName("wg") + } + var inet4Address netip.Addr + var inet6Address netip.Addr + if len(options.Address) > 0 { + if prefix := common.Find(options.Address, func(it netip.Prefix) bool { + return it.Addr().Is4() + }); prefix.IsValid() { + inet4Address = prefix.Addr() + } + } + if len(options.Address) > 0 { + if prefix := common.Find(options.Address, func(it netip.Prefix) bool { + return it.Addr().Is6() + }); prefix.IsValid() { + inet6Address = prefix.Addr() + } + } + return &systemDevice{ + options: options, + dialer: options.CreateDialer(options.Name), + events: make(chan wgTun.Event, 1), + inet4Address: inet4Address, + inet6Address: inet6Address, + }, nil +} + +func (w *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return w.dialer.DialContext(ctx, network, destination) +} + +func (w *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return w.dialer.ListenPacket(ctx, destination) +} + +func (w *systemDevice) Inet4Address() netip.Addr { + return w.inet4Address +} + +func (w *systemDevice) Inet6Address() netip.Addr { + return w.inet6Address +} + +func (w *systemDevice) SetDevice(device *device.Device) { +} + +func (w *systemDevice) Start() error { + networkManager := service.FromContext[adapter.NetworkManager](w.options.Context) + tunOptions := tun.Options{ + Name: w.options.Name, + Inet4Address: common.Filter(w.options.Address, func(it netip.Prefix) bool { + return it.Addr().Is4() + }), + Inet6Address: common.Filter(w.options.Address, func(it netip.Prefix) bool { + return it.Addr().Is6() + }), + MTU: w.options.MTU, + GSO: true, + InterfaceScope: true, + Inet4RouteAddress: common.Filter(w.options.AllowedAddress, func(it netip.Prefix) bool { + return it.Addr().Is4() + }), + Inet6RouteAddress: common.Filter(w.options.AllowedAddress, func(it netip.Prefix) bool { return it.Addr().Is6() }), + InterfaceMonitor: networkManager.InterfaceMonitor(), + InterfaceFinder: networkManager.InterfaceFinder(), + Logger: w.options.Logger, + } + // works with Linux, macOS with IFSCOPE routes, not tested on Windows + if runtime.GOOS == "darwin" { + tunOptions.AutoRoute = true + } + tunInterface, err := tun.New(tunOptions) + if err != nil { + return err + } + err = tunInterface.Start() + if err != nil { + return err + } + w.options.Logger.Info("started at ", w.options.Name) + w.device = tunInterface + batchTUN, isBatchTUN := tunInterface.(tun.LinuxTUN) + if isBatchTUN && batchTUN.BatchSize() > 1 { + w.batchDevice = batchTUN + } + w.events <- wgTun.EventUp + return nil +} + +func (w *systemDevice) File() *os.File { + return nil +} + +func (w *systemDevice) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + if w.batchDevice != nil { + count, err = w.batchDevice.BatchRead(bufs, offset-tun.PacketOffset, sizes) + } else { + sizes[0], err = w.device.Read(bufs[0][offset-tun.PacketOffset:]) + if err == nil { + count = 1 + } else if errors.Is(err, tun.ErrTooManySegments) { + err = wgTun.ErrTooManySegments + } + } + return +} + +func (w *systemDevice) Write(bufs [][]byte, offset int) (count int, err error) { + if w.batchDevice != nil { + return w.batchDevice.BatchWrite(bufs, offset) + } else { + for _, packet := range bufs { + if tun.PacketOffset > 0 { + common.ClearArray(packet[offset-tun.PacketOffset : offset]) + tun.PacketFillHeader(packet[offset-tun.PacketOffset:], tun.PacketIPVersion(packet[offset:])) + } + _, err = w.device.Write(packet[offset-tun.PacketOffset:]) + if err != nil { + return + } + } + } + // WireGuard will not read count + return +} + +func (w *systemDevice) Flush() error { + return nil +} + +func (w *systemDevice) MTU() (int, error) { + return int(w.options.MTU), nil +} + +func (w *systemDevice) Name() (string, error) { + return w.options.Name, nil +} + +func (w *systemDevice) Events() <-chan wgTun.Event { + return w.events +} + +func (w *systemDevice) Close() error { + close(w.events) + return w.device.Close() +} + +func (w *systemDevice) BatchSize() int { + if w.batchDevice != nil { + return w.batchDevice.BatchSize() + } + return 1 +} diff --git a/transport/wireguard/device_system_stack.go b/transport/wireguard/device_system_stack.go new file mode 100644 index 00000000..94fd6f4f --- /dev/null +++ b/transport/wireguard/device_system_stack.go @@ -0,0 +1,243 @@ +//go:build with_gvisor + +package wireguard + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/ping" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/wireguard-go/device" +) + +var _ Device = (*systemStackDevice)(nil) + +type systemStackDevice struct { + *systemDevice + ctx context.Context + logger logger.ContextLogger + stack *stack.Stack + endpoint *deviceEndpoint + writeBufs [][]byte +} + +func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { + system, err := newSystemDevice(options) + if err != nil { + return nil, err + } + endpoint := &deviceEndpoint{ + mtu: options.MTU, + done: make(chan struct{}), + } + ipStack, err := tun.NewGVisorStackWithOptions(endpoint, stack.NICOptions{}, true) + if err != nil { + return nil, err + } + var ( + inet4Address netip.Addr + inet6Address netip.Addr + ) + for _, prefix := range options.Address { + addr := tun.AddressFromAddr(prefix.Addr()) + protoAddr := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: addr, + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + inet4Address = prefix.Addr() + protoAddr.Protocol = ipv4.ProtocolNumber + } else { + inet6Address = prefix.Addr() + protoAddr.Protocol = ipv6.ProtocolNumber + } + gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, protoAddr, stack.AddressProperties{}) + if gErr != nil { + return nil, E.New("parse local address ", protoAddr.AddressWithPrefix, ": ", gErr.String()) + } + } + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout) + icmpForwarder.SetLocalAddresses(inet4Address, inet6Address) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + } + return &systemStackDevice{ + ctx: options.Context, + logger: options.Logger, + systemDevice: system, + stack: ipStack, + endpoint: endpoint, + }, nil +} + +func (w *systemStackDevice) SetDevice(device *device.Device) { + w.endpoint.device = device +} + +func (w *systemStackDevice) Write(bufs [][]byte, offset int) (count int, err error) { + if w.batchDevice != nil { + w.writeBufs = w.writeBufs[:0] + for _, packet := range bufs { + if !w.writeStack(packet[offset:]) { + w.writeBufs = append(w.writeBufs, packet) + } + } + if len(w.writeBufs) > 0 { + return w.batchDevice.BatchWrite(bufs, offset) + } + } else { + for _, packet := range bufs { + if !w.writeStack(packet[offset:]) { + if tun.PacketOffset > 0 { + common.ClearArray(packet[offset-tun.PacketOffset : offset]) + tun.PacketFillHeader(packet[offset-tun.PacketOffset:], tun.PacketIPVersion(packet[offset:])) + } + _, err = w.device.Write(packet[offset-tun.PacketOffset:]) + } + if err != nil { + return + } + } + } + // WireGuard will not read count + return +} + +func (w *systemStackDevice) Close() error { + close(w.endpoint.done) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + return w.systemDevice.Close() +} + +func (w *systemStackDevice) writeStack(packet []byte) bool { + var ( + networkProtocol tcpip.NetworkProtocolNumber + destination netip.Addr + ) + switch header.IPVersion(packet) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + destination = netip.AddrFrom4(header.IPv4(packet).DestinationAddress().As4()) + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + destination = netip.AddrFrom16(header.IPv6(packet).DestinationAddress().As16()) + } + for _, prefix := range w.options.Address { + if prefix.Contains(destination) { + return false + } + } + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(packet), + }) + w.endpoint.dispatcher.DeliverNetworkPacket(networkProtocol, packetBuffer) + packetBuffer.DecRef() + return true +} + +func (w *systemStackDevice) CreateDestination(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + ctx := log.ContextWithNewID(w.ctx) + destination, err := ping.ConnectGVisor( + ctx, w.logger, + metadata.Source.Addr, metadata.Destination.Addr, + routeContext, + w.stack, + w.inet4Address, w.inet6Address, + timeout, + ) + if err != nil { + return nil, err + } + w.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString()) + return destination, nil +} + +type deviceEndpoint struct { + mtu uint32 + done chan struct{} + device *device.Device + dispatcher stack.NetworkDispatcher +} + +func (ep *deviceEndpoint) MTU() uint32 { + return ep.mtu +} + +func (ep *deviceEndpoint) SetMTU(mtu uint32) { +} + +func (ep *deviceEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (ep *deviceEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (ep *deviceEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (ep *deviceEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (ep *deviceEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + ep.dispatcher = dispatcher +} + +func (ep *deviceEndpoint) IsAttached() bool { + return ep.dispatcher != nil +} + +func (ep *deviceEndpoint) Wait() { +} + +func (ep *deviceEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (ep *deviceEndpoint) AddHeader(buffer *stack.PacketBuffer) { +} + +func (ep *deviceEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool { + return true +} + +func (ep *deviceEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + for _, packetBuffer := range list.AsSlice() { + destination := packetBuffer.Network().DestinationAddress() + ep.device.InputPacket(destination.AsSlice(), packetBuffer.AsSlices()) + } + return list.Len(), nil +} + +func (ep *deviceEndpoint) Close() { +} + +func (ep *deviceEndpoint) SetOnCloseAction(f func()) { +} diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go new file mode 100644 index 00000000..3a02e17a --- /dev/null +++ b/transport/wireguard/endpoint.go @@ -0,0 +1,290 @@ +package wireguard + +import ( + "context" + "encoding/base64" + "encoding/hex" + "fmt" + "net" + "net/netip" + "os" + "reflect" + "strings" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" + "github.com/sagernet/wireguard-go/conn" + "github.com/sagernet/wireguard-go/device" + + "go4.org/netipx" +) + +type Endpoint struct { + options EndpointOptions + peers []peerConfig + ipcConf string + allowedAddress []netip.Prefix + tunDevice Device + natDevice NatDevice + device *device.Device + allowedIPs *device.AllowedIPs + pause pause.Manager + pauseCallback *list.Element[pause.Callback] +} + +func NewEndpoint(options EndpointOptions) (*Endpoint, error) { + if options.PrivateKey == "" { + return nil, E.New("missing private key") + } + privateKeyBytes, err := base64.StdEncoding.DecodeString(options.PrivateKey) + if err != nil { + return nil, E.Cause(err, "decode private key") + } + privateKey := hex.EncodeToString(privateKeyBytes) + ipcConf := "private_key=" + privateKey + if options.ListenPort != 0 { + ipcConf += "\nlisten_port=" + F.ToString(options.ListenPort) + } + var peers []peerConfig + for peerIndex, rawPeer := range options.Peers { + peer := peerConfig{ + allowedIPs: rawPeer.AllowedIPs, + keepalive: rawPeer.PersistentKeepaliveInterval, + } + if rawPeer.Endpoint.Addr.IsValid() { + peer.endpoint = rawPeer.Endpoint.AddrPort() + } else if rawPeer.Endpoint.IsDomain() { + peer.destination = rawPeer.Endpoint + } + publicKeyBytes, err := base64.StdEncoding.DecodeString(rawPeer.PublicKey) + if err != nil { + return nil, E.Cause(err, "decode public key for peer ", peerIndex) + } + peer.publicKeyHex = hex.EncodeToString(publicKeyBytes) + if rawPeer.PreSharedKey != "" { + preSharedKeyBytes, err := base64.StdEncoding.DecodeString(rawPeer.PreSharedKey) + if err != nil { + return nil, E.Cause(err, "decode pre shared key for peer ", peerIndex) + } + peer.preSharedKeyHex = hex.EncodeToString(preSharedKeyBytes) + } + if len(rawPeer.AllowedIPs) == 0 { + return nil, E.New("missing allowed ips for peer ", peerIndex) + } + if len(rawPeer.Reserved) > 0 { + if len(rawPeer.Reserved) != 3 { + return nil, E.New("invalid reserved value for peer ", peerIndex, ", required 3 bytes, got ", len(peer.reserved)) + } + copy(peer.reserved[:], rawPeer.Reserved[:]) + } + peers = append(peers, peer) + } + var allowedPrefixBuilder netipx.IPSetBuilder + for _, peer := range options.Peers { + for _, prefix := range peer.AllowedIPs { + allowedPrefixBuilder.AddPrefix(prefix) + } + } + allowedIPSet, err := allowedPrefixBuilder.IPSet() + if err != nil { + return nil, err + } + allowedAddresses := allowedIPSet.Prefixes() + if options.MTU == 0 { + options.MTU = 1408 + } + deviceOptions := DeviceOptions{ + Context: options.Context, + Logger: options.Logger, + System: options.System, + Handler: options.Handler, + UDPTimeout: options.UDPTimeout, + CreateDialer: options.CreateDialer, + Name: options.Name, + MTU: options.MTU, + Address: options.Address, + AllowedAddress: allowedAddresses, + } + tunDevice, err := NewDevice(deviceOptions) + if err != nil { + return nil, E.Cause(err, "create WireGuard device") + } + natDevice, isNatDevice := tunDevice.(NatDevice) + if !isNatDevice { + natDevice = NewNATDevice(options.Context, options.Logger, tunDevice) + } + return &Endpoint{ + options: options, + peers: peers, + ipcConf: ipcConf, + allowedAddress: allowedAddresses, + tunDevice: tunDevice, + natDevice: natDevice, + }, nil +} + +func (e *Endpoint) Start(resolve bool) error { + if common.Any(e.peers, func(peer peerConfig) bool { + return !peer.endpoint.IsValid() && peer.destination.IsDomain() + }) { + if !resolve { + return nil + } + for peerIndex, peer := range e.peers { + if peer.endpoint.IsValid() || !peer.destination.IsDomain() { + continue + } + destinationAddress, err := e.options.ResolvePeer(peer.destination.Fqdn) + if err != nil { + return E.Cause(err, "resolve endpoint domain for peer[", peerIndex, "]: ", peer.destination) + } + e.peers[peerIndex].endpoint = netip.AddrPortFrom(destinationAddress, peer.destination.Port) + } + } else if resolve { + return nil + } + var bind conn.Bind + wgListener, isWgListener := common.Cast[dialer.WireGuardListener](e.options.Dialer) + if isWgListener { + bind = conn.NewStdNetBind(wgListener.WireGuardControl()) + } else { + var ( + isConnect bool + connectAddr netip.AddrPort + reserved [3]uint8 + ) + if len(e.peers) == 1 && e.peers[0].endpoint.IsValid() { + isConnect = true + connectAddr = e.peers[0].endpoint + reserved = e.peers[0].reserved + } + bind = NewClientBind(e.options.Context, e.options.Logger, e.options.Dialer, isConnect, connectAddr, reserved) + } + if isWgListener || len(e.peers) > 1 { + for _, peer := range e.peers { + if peer.reserved != [3]uint8{} { + bind.SetReservedForEndpoint(peer.endpoint, peer.reserved) + } + } + } + err := e.tunDevice.Start() + if err != nil { + return err + } + logger := &device.Logger{ + Verbosef: func(format string, args ...interface{}) { + e.options.Logger.Debug(fmt.Sprintf(strings.ToLower(format), args...)) + }, + Errorf: func(format string, args ...interface{}) { + e.options.Logger.Error(fmt.Sprintf(strings.ToLower(format), args...)) + }, + } + var deviceInput Device + if e.natDevice != nil { + deviceInput = e.natDevice + } else { + deviceInput = e.tunDevice + } + wgDevice := device.NewDevice(e.options.Context, deviceInput, bind, logger, e.options.Workers) + e.tunDevice.SetDevice(wgDevice) + ipcConf := e.ipcConf + for _, peer := range e.peers { + ipcConf += peer.GenerateIpcLines() + } + err = wgDevice.IpcSet(ipcConf) + if err != nil { + return E.Cause(err, "setup wireguard: \n", ipcConf) + } + e.device = wgDevice + e.pause = service.FromContext[pause.Manager](e.options.Context) + if e.pause != nil { + e.pauseCallback = e.pause.RegisterCallback(e.onPauseUpdated) + } + e.allowedIPs = (*device.AllowedIPs)(unsafe.Pointer(reflect.Indirect(reflect.ValueOf(wgDevice)).FieldByName("allowedips").UnsafeAddr())) + return nil +} + +func (e *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return e.tunDevice.DialContext(ctx, network, destination) +} + +func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return e.tunDevice.ListenPacket(ctx, destination) +} + +func (e *Endpoint) Close() error { + if e.pauseCallback != nil { + e.pause.UnregisterCallback(e.pauseCallback) + } + if e.device != nil { + e.device.Down() + e.device.Close() + } + return nil +} + +func (e *Endpoint) Lookup(address netip.Addr) *device.Peer { + if e.allowedIPs == nil { + return nil + } + return e.allowedIPs.Lookup(address.AsSlice()) +} + +func (e *Endpoint) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + if e.natDevice == nil { + return nil, os.ErrInvalid + } + return e.natDevice.CreateDestination(metadata, routeContext, timeout) +} + +func (e *Endpoint) onPauseUpdated(event int) { + switch event { + case pause.EventDevicePaused, pause.EventNetworkPause: + e.device.Down() + case pause.EventDeviceWake, pause.EventNetworkWake: + e.device.Up() + } +} + +type peerConfig struct { + destination M.Socksaddr + endpoint netip.AddrPort + publicKeyHex string + preSharedKeyHex string + allowedIPs []netip.Prefix + keepalive uint16 + reserved [3]uint8 +} + +func (c peerConfig) GenerateIpcLines() string { + ipcLines := "\npublic_key=" + c.publicKeyHex + if c.endpoint.IsValid() { + ipcLines += "\nendpoint=" + c.endpoint.String() + } + if c.preSharedKeyHex != "" { + ipcLines += "\npreshared_key=" + c.preSharedKeyHex + } + for _, allowedIP := range c.allowedIPs { + ipcLines += "\nallowed_ip=" + allowedIP.String() + } + if c.keepalive > 0 { + ipcLines += "\npersistent_keepalive_interval=" + F.ToString(c.keepalive) + } + return ipcLines +} diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go new file mode 100644 index 00000000..bb9a46e6 --- /dev/null +++ b/transport/wireguard/endpoint_options.go @@ -0,0 +1,39 @@ +package wireguard + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type EndpointOptions struct { + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + Dialer N.Dialer + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + PrivateKey string + ListenPort uint16 + ResolvePeer func(domain string) (netip.Addr, error) + Peers []PeerOptions + Workers int +} + +type PeerOptions struct { + Endpoint M.Socksaddr + PublicKey string + PreSharedKey string + AllowedIPs []netip.Prefix + PersistentKeepaliveInterval uint16 + Reserved []uint8 +} From b71056eea78cfeead8d91f5c7ea68591c159b769 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 22:45:24 +0800 Subject: [PATCH 02/97] first commit --- constant/proxy.go | 1 + option/xboard.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 option/xboard.go diff --git a/constant/proxy.go b/constant/proxy.go index ffec8025..d7ba46c9 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -34,6 +34,7 @@ const ( TypeOOMKiller = "oom-killer" TypeACME = "acme" TypeCloudflareOriginCA = "cloudflare-origin-ca" + TypeXBoard = "xboard" ) const ( diff --git a/option/xboard.go b/option/xboard.go new file mode 100644 index 00000000..21d319ee --- /dev/null +++ b/option/xboard.go @@ -0,0 +1,14 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type XBoardServiceOptions struct { + PanelURL string `json:"panel_url"` + Key string `json:"key"` + NodeID int `json:"node_id"` + NodeOptions badoption.RawMessage `json:"node_options,omitempty"` + SyncInterval badoption.Duration `json:"sync_interval,omitempty"` + ReportInterval badoption.Duration `json:"report_interval,omitempty"` +} From dcf4f06dab302d5a5dd1bbd21635b0c58c23d700 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 22:58:28 +0800 Subject: [PATCH 03/97] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E4=BA=8EXboar?= =?UTF-8?q?d=E7=9A=84=E5=AE=8C=E6=95=B4=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .config.example | 12 ++ adapter/ssm.go | 2 +- include/registry.go | 2 + install.sh | 201 ++++++++++++++++++ protocol/anytls/inbound.go | 52 ++++- protocol/shadowsocks/inbound_multi.go | 2 +- protocol/vless/inbound.go | 52 +++++ protocol/vmess/inbound.go | 43 ++++ service/ssmapi/user.go | 2 +- service/xboard/service.go | 294 ++++++++++++++++++++++++++ 10 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 .config.example create mode 100644 install.sh create mode 100644 service/xboard/service.go diff --git a/.config.example b/.config.example new file mode 100644 index 00000000..7089354d --- /dev/null +++ b/.config.example @@ -0,0 +1,12 @@ +{ + "services": [ + { + "type": "xboard", + "panel_url": "https://your-panel.com", + "key": "your-node-key", + "node_id": 1, + "sync_interval": "1m", + "report_interval": "1m" + } + ] +} diff --git a/adapter/ssm.go b/adapter/ssm.go index caab9221..3862e94f 100644 --- a/adapter/ssm.go +++ b/adapter/ssm.go @@ -9,7 +9,7 @@ import ( type ManagedSSMServer interface { Inbound SetTracker(tracker SSMTracker) - UpdateUsers(users []string, uPSKs []string) error + UpdateUsers(users []string, uPSKs []string, flows []string) error } type SSMTracker interface { diff --git a/include/registry.go b/include/registry.go index 5a1a2f97..742a736e 100644 --- a/include/registry.go +++ b/include/registry.go @@ -38,6 +38,7 @@ import ( originca "github.com/sagernet/sing-box/service/origin_ca" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" + "github.com/sagernet/sing-box/service/xboard" E "github.com/sagernet/sing/common/exceptions" ) @@ -133,6 +134,7 @@ func ServiceRegistry() *service.Registry { resolved.RegisterService(registry) ssmapi.RegisterService(registry) + xboard.RegisterService(registry) registerDERPService(registry) registerCCMService(registry) diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..c109504b --- /dev/null +++ b/install.sh @@ -0,0 +1,201 @@ +#!/bin/bash + +# sing-box Xboard Integration Installation Script +# This script automates the installation and configuration of sing-box with Xboard support. + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Configuration +CONFIG_DIR="/etc/sing-box" +CONFIG_FILE="$CONFIG_DIR/config.json" +BINARY_PATH="/usr/local/bin/sing-box" +SERVICE_FILE="/etc/systemd/system/sing-box.service" + +echo -e "${GREEN}Welcome to sing-box Xboard Installation Script${NC}" + +# Check root +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}This script must be run as root${NC}" + exit 1 +fi + +# Detect Architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) BINARY_ARCH="amd64" ;; + aarch64) BINARY_ARCH="arm64" ;; + *) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;; +esac + +# Interactive Prompts +read -p "Enter Panel URL (e.g., https://yourbase.com): " PANEL_URL +read -p "Enter Node ID: " NODE_ID +read -p "Enter Panel Token (Node Key): " PANEL_TOKEN + +if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then + echo -e "${RED}All fields are required!${NC}" + exit 1 +fi + +# Clean up trailing slash +PANEL_URL="${PANEL_URL%/}" + +# Prepare directories +mkdir -p "$CONFIG_DIR" +mkdir -p "/var/lib/sing-box" + +# Check and Install Go +install_go() { + echo -e "${YELLOW}Checking Go environment...${NC}" + if command -v go >/dev/null 2>&1; then + GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//' | cut -d. -f1,2) + if [[ "$(printf '%s\n' "1.24" "$GO_VERSION" | sort -V | head -n1)" == "1.24" ]]; then + echo -e "${GREEN}Go $GO_VERSION already installed.${NC}" + return + fi + fi + + echo -e "${YELLOW}Installing Go 1.24.7...${NC}" + GO_TAR="go1.24.7.linux-$BINARY_ARCH.tar.gz" + curl -L "https://golang.org/dl/$GO_TAR" -o "$GO_TAR" + rm -rf /usr/local/go && tar -C /usr/local -xzf "$GO_TAR" + rm "$GO_TAR" + + # Add to PATH for current session + export PATH=$PATH:/usr/local/go/bin + if ! grep -q "/usr/local/go/bin" ~/.bashrc; then + echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc + fi + echo -e "${GREEN}Go installed successfully.${NC}" +} + +# Build sing-box +build_sing_box() { + echo -e "${YELLOW}Building sing-box from source...${NC}" + + # Check if we are in the source directory + if [[ ! -f "go.mod" ]]; then + echo -e "${YELLOW}Source not found in current directory. Cloning repository...${NC}" + if ! command -v git >/dev/null 2>&1; then + echo -e "${YELLOW}Installing git...${NC}" + apt-get update && apt-get install -y git || yum install -y git + fi + # Replace with your repository URL + git clone https://github.com/sagernet/sing-box.git sing-box-src + cd sing-box-src + fi + + # Build params from Makefile + VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") + TAGS="with_quic,with_shadowsocks,with_v2rayapi,with_utls,with_clash_api,with_gvisor" + + echo -e "${YELLOW}Running go build...${NC}" + go build -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" -tags "$TAGS" ./cmd/sing-box + + mv sing-box "$BINARY_PATH" + chmod +x "$BINARY_PATH" + echo -e "${GREEN}sing-box built and installed to $BINARY_PATH${NC}" +} + +install_go +build_sing_box + +# Generate Configuration +echo -e "${YELLOW}Generating configuration...${NC}" +cat > "$CONFIG_FILE" < "$SERVICE_FILE" < 0 { + paddingScheme = []byte(strings.Join(h.options.PaddingScheme, "\n")) + } + + anytlsUsers := make([]anytls.User, len(users)) + for i := range users { + anytlsUsers[i] = anytls.User{ + Name: users[i], + Password: passwords[i], + } + } + + service, err := anytls.NewService(anytls.ServiceConfig{ + Users: anytlsUsers, + PaddingScheme: paddingScheme, + Handler: (*inboundHandler)(h), + Logger: h.logger, + }) + if err != nil { + return err + } + h.service = service + return nil } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) { @@ -42,6 +84,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag), router: uot.NewRouter(router, logger), logger: logger, + options: options, } if options.TLS != nil && options.TLS.Enabled { @@ -106,7 +149,14 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } - err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + h.ssmMutex.RLock() + tracker := h.tracker + service := h.service + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } + err := service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go index 7ff92646..fb0b1cae 100644 --- a/protocol/shadowsocks/inbound_multi.go +++ b/protocol/shadowsocks/inbound_multi.go @@ -122,7 +122,7 @@ func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) { h.tracker = tracker } -func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error { +func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string, flows []string) error { err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int { return index }), uPSKs) diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 75cd4124..53322cc0 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -43,6 +44,45 @@ type Inbound struct { service *vless.Service[int] tlsConfig tls.ServerConfig transport adapter.V2RayServerTransport + tracker adapter.SSMTracker + ssmMutex sync.RWMutex +} + +var _ adapter.ManagedSSMServer = (*Inbound)(nil) + +func (h *Inbound) SetTracker(tracker adapter.SSMTracker) { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + h.tracker = tracker +} + +func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + newUsers := make([]option.VLESSUser, len(users)) + for i := range users { + flow := "" + if i < len(flows) { + flow = flows[i] + } + if flow == "" { + flow = "xtls-rprx-vision" + } + newUsers[i] = option.VLESSUser{ + Name: users[i], + UUID: uuids[i], + Flow: flow, + } + } + h.users = newUsers + h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, _ option.VLESSUser) int { + return index + }), common.Map(h.users, func(it option.VLESSUser) string { + return it.UUID + }), common.Map(h.users, func(it option.VLESSUser) string { + return it.Flow + })) + return nil } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -157,6 +197,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) @@ -203,6 +249,12 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackPacketConnection(conn, metadata) + } h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 4e9c763c..e3f4a388 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -44,6 +45,36 @@ type Inbound struct { users []option.VMessUser tlsConfig tls.ServerConfig transport adapter.V2RayServerTransport + tracker adapter.SSMTracker + ssmMutex sync.RWMutex +} + +var _ adapter.ManagedSSMServer = (*Inbound)(nil) + +func (h *Inbound) SetTracker(tracker adapter.SSMTracker) { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + h.tracker = tracker +} + +func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + newUsers := make([]option.VMessUser, len(users)) + for i := range users { + newUsers[i] = option.VMessUser{ + Name: users[i], + UUID: uuids[i], + } + } + h.users = newUsers + return h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, it option.VMessUser) int { + return index + }), common.Map(h.users, func(it option.VMessUser) string { + return it.UUID + }), common.Map(h.users, func(it option.VMessUser) int { + return it.AlterId + })) } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) { @@ -163,6 +194,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) @@ -209,6 +246,12 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackPacketConnection(conn, metadata) + } h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } diff --git a/service/ssmapi/user.go b/service/ssmapi/user.go index 26bc621a..21aba15c 100644 --- a/service/ssmapi/user.go +++ b/service/ssmapi/user.go @@ -29,7 +29,7 @@ func (m *UserManager) postUpdate(updated bool) error { users = append(users, username) uPSKs = append(uPSKs, password) } - err := m.server.UpdateUsers(users, uPSKs) + err := m.server.UpdateUsers(users, uPSKs, nil) if err != nil { return err } diff --git a/service/xboard/service.go b/service/xboard/service.go new file mode 100644 index 00000000..05c2d048 --- /dev/null +++ b/service/xboard/service.go @@ -0,0 +1,294 @@ +package xboard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/ssmapi" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.XBoardServiceOptions](registry, C.TypeXBoard, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + options option.XBoardServiceOptions + traffics map[string]*ssmapi.TrafficManager + users map[string]*ssmapi.UserManager + servers map[string]adapter.ManagedSSMServer + localUsers map[string]userData + inboundTags []string + syncTicker *time.Ticker + reportTicker *time.Ticker + access sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) { + ctx, cancel := context.WithCancel(ctx) + s := &Service{ + Adapter: boxService.NewAdapter(C.TypeXBoard, tag), + ctx: ctx, + cancel: cancel, + logger: logger, + options: options, + httpClient: &http.Client{Timeout: 10 * time.Second}, + traffics: make(map[string]*ssmapi.TrafficManager), + users: make(map[string]*ssmapi.UserManager), + servers: make(map[string]adapter.ManagedSSMServer), + syncTicker: time.NewTicker(time.Duration(options.SyncInterval)), + reportTicker: time.NewTicker(time.Duration(options.ReportInterval)), + } + + if s.options.SyncInterval == 0 { + s.syncTicker.Stop() + s.syncTicker = time.NewTicker(1 * time.Minute) + } + if s.options.ReportInterval == 0 { + s.reportTicker.Stop() + s.reportTicker = time.NewTicker(1 * time.Minute) + } + + inboundManager := service.FromContext[adapter.InboundManager](ctx) + allInbounds := inboundManager.List() + for _, inbound := range allInbounds { + managedServer, isManaged := inbound.(adapter.ManagedSSMServer) + if isManaged { + traffic := ssmapi.NewTrafficManager() + managedServer.SetTracker(traffic) + user := ssmapi.NewUserManager(managedServer, traffic) + s.traffics[inbound.Tag()] = traffic + s.users[inbound.Tag()] = user + s.servers[inbound.Tag()] = managedServer + s.inboundTags = append(s.inboundTags, inbound.Tag()) + } + } + + if len(s.inboundTags) == 0 { + logger.Warn("Xboard service: no managed inbounds found") + } + + return s, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + go s.loop() + return nil +} + +func (s *Service) loop() { + // Initial sync + s.syncUsers() + + for { + select { + case <-s.ctx.Done(): + return + case <-s.syncTicker.C: + s.syncUsers() + case <-s.reportTicker.C: + s.reportTraffic() + } + } +} + +type userData struct { + ID int + Email string + Key string + Flow string +} + +func (s *Service) syncUsers() { + s.logger.Info("Xboard sync users...") + xUsers, err := s.fetchUsers() + if err != nil { + s.logger.Error("Xboard sync error: ", err) + return + } + + s.access.Lock() + defer s.access.Unlock() + + newUsers := make(map[string]userData) + for _, u := range xUsers { + key := u.UUID + if key == "" { + key = u.Passwd + } + if key == "" { + continue + } + newUsers[u.Email] = userData{ + ID: u.ID, + Email: u.Email, + Key: key, + Flow: u.Flow, + } + } + + for tag, server := range s.servers { + // Update users in each manager + users := make([]string, 0, len(newUsers)) + keys := make([]string, 0, len(newUsers)) + flows := make([]string, 0, len(newUsers)) + for _, u := range newUsers { + users = append(users, u.Email) + keys = append(keys, u.Key) + flows = append(flows, u.Flow) + } + err = server.UpdateUsers(users, keys, flows) + if err != nil { + s.logger.Error("Update users for inbound ", tag, ": ", err) + } + } + + // Update local ID mapping + s.localUsers = newUsers + + s.logger.Info("Xboard sync completed, total users: ", len(xUsers)) +} + +func (s *Service) reportTraffic() { + s.logger.Trace("Xboard reporting traffic...") + + s.access.Lock() + localUsers := s.localUsers + s.access.Unlock() + + if len(localUsers) == 0 { + return + } + + type pushItem struct { + UserID int `json:"user_id"` + U int64 `json:"u"` + D int64 `json:"d"` + } + + usageMap := make(map[int]*pushItem) + + for _, trafficManager := range s.traffics { + users := make([]*ssmapi.UserObject, 0, len(localUsers)) + for email := range localUsers { + users = append(users, &ssmapi.UserObject{UserName: email}) + } + + // Read incremental usage + trafficManager.ReadUsers(users, true) + + for _, u := range users { + if u.UplinkBytes == 0 && u.DownlinkBytes == 0 { + continue + } + meta, ok := localUsers[u.UserName] + if !ok { + continue + } + item, ok := usageMap[meta.ID] + if !ok { + item = &pushItem{UserID: meta.ID} + usageMap[meta.ID] = item + } + item.U += u.UplinkBytes + item.D += u.DownlinkBytes + } + } + + if len(usageMap) == 0 { + return + } + + pushData := make([]*pushItem, 0, len(usageMap)) + for _, item := range usageMap { + pushData = append(pushData, item) + } + + err := s.pushTraffic(pushData) + if err != nil { + s.logger.Error("Xboard report error: ", err) + } else { + s.logger.Info("Xboard report completed, users reported: ", len(pushData)) + } +} + +func (s *Service) pushTraffic(data any) error { + url := fmt.Sprintf("%s/api/v1/server/UniProxy/push?node_id=%d", s.options.PanelURL, s.options.NodeID) + body, _ := json.Marshal(data) + + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body)) + req.Header.Set("Authorization", s.options.Key) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return E.New("failed to push traffic, status: ", resp.Status) + } + return nil +} + +func (s *Service) Close() error { + s.cancel() + s.syncTicker.Stop() + s.reportTicker.Stop() + return nil +} + +// Xboard User Model +type XUser struct { + ID int `json:"id"` + Email string `json:"email"` + UUID string `json:"uuid"` + Passwd string `json:"passwd"` + Flow string `json:"flow"` +} + +func (s *Service) fetchUsers() ([]XUser, error) { + url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d", s.options.PanelURL, s.options.NodeID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", s.options.Key) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, E.New("failed to fetch users, status: ", resp.Status) + } + + var result struct { + Data []XUser `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, err + } + return result.Data, nil +} From 5d63f2581a7db91c3be46f51828cf75cf4476c77 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:03:20 +0800 Subject: [PATCH 04/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index c109504b..3772ac99 100644 --- a/install.sh +++ b/install.sh @@ -86,21 +86,31 @@ build_sing_box() { echo -e "${YELLOW}Installing git...${NC}" apt-get update && apt-get install -y git || yum install -y git fi - # Replace with your repository URL git clone https://github.com/sagernet/sing-box.git sing-box-src cd sing-box-src + else + echo -e "${GREEN}Found go.mod in current directory. Building from local source.${NC}" fi # Build params from Makefile VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") TAGS="with_quic,with_shadowsocks,with_v2rayapi,with_utls,with_clash_api,with_gvisor" - echo -e "${YELLOW}Running go build...${NC}" - go build -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" -tags "$TAGS" ./cmd/sing-box - - mv sing-box "$BINARY_PATH" - chmod +x "$BINARY_PATH" - echo -e "${GREEN}sing-box built and installed to $BINARY_PATH${NC}" + echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" + # Use -o to be explicit about output location + go build -v -trimpath \ + -o "$BINARY_PATH" \ + -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" \ + -tags "$TAGS" \ + ./cmd/sing-box + + if [[ -f "$BINARY_PATH" ]]; then + chmod +x "$BINARY_PATH" + echo -e "${GREEN}sing-box compiled successfully and installed to $BINARY_PATH${NC}" + else + echo -e "${RED}Compilation failed: binary not found!${NC}" + exit 1 + fi } install_go From 81d814515f4e45b493ea5746dd91f1d6c83ca51a Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:09:07 +0800 Subject: [PATCH 05/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E7=BC=96=E8=AF=91=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 16 +++++++++++++--- option/xboard.go | 10 ++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 3772ac99..a3ad2895 100644 --- a/install.sh +++ b/install.sh @@ -94,15 +94,25 @@ build_sing_box() { # Build params from Makefile VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") - TAGS="with_quic,with_shadowsocks,with_v2rayapi,with_utls,with_clash_api,with_gvisor" + # Reduced tags for safer build on smaller servers + TAGS="with_quic,with_utls,with_clash_api,with_gvisor" echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" + echo -e "${YELLOW}Note: Using -p 1 to save memory and avoid silent crashes.${NC}" + # Use -o to be explicit about output location - go build -v -trimpath \ + # Use -p 1 to limit parallel builds (prevent OOM spikes) + # Redirect stderr to stdout to see errors clearly + if ! go build -v -p 1 -trimpath \ -o "$BINARY_PATH" \ -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" \ -tags "$TAGS" \ - ./cmd/sing-box + ./cmd/sing-box 2>&1; then + echo -e "${RED}Compilation process failed or was terminated.${NC}" + echo -e "${YELLOW}Checking system status...${NC}" + free -m + exit 1 + fi if [[ -f "$BINARY_PATH" ]]; then chmod +x "$BINARY_PATH" diff --git a/option/xboard.go b/option/xboard.go index 21d319ee..fb21f7a5 100644 --- a/option/xboard.go +++ b/option/xboard.go @@ -1,14 +1,16 @@ package option import ( + "encoding/json" + "github.com/sagernet/sing/common/json/badoption" ) type XBoardServiceOptions struct { - PanelURL string `json:"panel_url"` - Key string `json:"key"` - NodeID int `json:"node_id"` - NodeOptions badoption.RawMessage `json:"node_options,omitempty"` + PanelURL string `json:"panel_url"` + Key string `json:"key"` + NodeID int `json:"node_id"` + NodeOptions json.RawMessage `json:"node_options,omitempty"` SyncInterval badoption.Duration `json:"sync_interval,omitempty"` ReportInterval badoption.Duration `json:"report_interval,omitempty"` } From 3f997482decfbfc8bd06aee5a9d6d7e156921284 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:13:12 +0800 Subject: [PATCH 06/97] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 26 +++++++++++++------------- service/xboard/service.go | 5 ++--- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/install.sh b/install.sh index a3ad2895..2a3aa5ff 100644 --- a/install.sh +++ b/install.sh @@ -33,19 +33,6 @@ case $ARCH in *) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;; esac -# Interactive Prompts -read -p "Enter Panel URL (e.g., https://yourbase.com): " PANEL_URL -read -p "Enter Node ID: " NODE_ID -read -p "Enter Panel Token (Node Key): " PANEL_TOKEN - -if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then - echo -e "${RED}All fields are required!${NC}" - exit 1 -fi - -# Clean up trailing slash -PANEL_URL="${PANEL_URL%/}" - # Prepare directories mkdir -p "$CONFIG_DIR" mkdir -p "/var/lib/sing-box" @@ -126,6 +113,19 @@ build_sing_box() { install_go build_sing_box +# Interactive Prompts +read -p "Enter Panel URL (e.g., https://yourbase.com): " PANEL_URL +read -p "Enter Node ID: " NODE_ID +read -p "Enter Panel Token (Node Key): " PANEL_TOKEN + +if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then + echo -e "${RED}All fields are required!${NC}" + exit 1 +fi + +# Clean up trailing slash +PANEL_URL="${PANEL_URL%/}" + # Generate Configuration echo -e "${YELLOW}Generating configuration...${NC}" cat > "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:15:11 +0800 Subject: [PATCH 07/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 2a3aa5ff..8d83285e 100644 --- a/install.sh +++ b/install.sh @@ -157,7 +157,6 @@ cat > "$CONFIG_FILE" < "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:18:12 +0800 Subject: [PATCH 08/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 8d83285e..9de4358e 100644 --- a/install.sh +++ b/install.sh @@ -156,7 +156,6 @@ cat > "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:20:33 +0800 Subject: [PATCH 09/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/install.sh b/install.sh index 9de4358e..75a11dad 100644 --- a/install.sh +++ b/install.sh @@ -140,6 +140,16 @@ cat > "$CONFIG_FILE" < "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:24:40 +0800 Subject: [PATCH 10/97] =?UTF-8?q?=E6=9E=B6=E6=9E=84=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 26 +----- service/xboard/service.go | 176 +++++++++++++++++++++++++++++++------- 2 files changed, 147 insertions(+), 55 deletions(-) diff --git a/install.sh b/install.sh index 75a11dad..cf5e62cb 100644 --- a/install.sh +++ b/install.sh @@ -160,27 +160,7 @@ cat > "$CONFIG_FILE" < "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:26:34 +0800 Subject: [PATCH 11/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++++ install.sh | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d0181ca6 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# sing-box Xboard Node Configuration +PANEL_URL=https://your-panel.com +PANEL_TOKEN=your_node_key +NODE_ID=1 diff --git a/install.sh b/install.sh index cf5e62cb..dd97da3b 100644 --- a/install.sh +++ b/install.sh @@ -113,10 +113,21 @@ build_sing_box() { install_go build_sing_box +# Load .env if exists +if [[ -f ".env" ]]; then + echo -e "${YELLOW}Loading configuration from .env...${NC}" + source .env +fi + # Interactive Prompts -read -p "Enter Panel URL (e.g., https://yourbase.com): " PANEL_URL -read -p "Enter Node ID: " NODE_ID -read -p "Enter Panel Token (Node Key): " PANEL_TOKEN +read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL +PANEL_URL=${INPUT_URL:-$PANEL_URL} + +read -p "Enter Node ID [${NODE_ID}]: " INPUT_ID +NODE_ID=${INPUT_ID:-$NODE_ID} + +read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN +PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then echo -e "${RED}All fields are required!${NC}" From 3c509443430a6f07c9bb14ea4f937067dcbe401d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:29:36 +0800 Subject: [PATCH 12/97] =?UTF-8?q?=E5=8A=A8=E4=BD=9C:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=20github.com/sagernet/sing/common/json/badoption=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index 0cb25d37..c3c14425 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -16,6 +16,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/service" ) From 905797dcca0c7cb00ff0dfa9fb5479f270774923 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:35:16 +0800 Subject: [PATCH 13/97] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index c3c14425..85bdbfbc 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -191,6 +191,7 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { url := fmt.Sprintf("%s/api/v1/server/UniProxy/config?node_id=%d", s.options.PanelURL, s.options.NodeID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", s.options.Key) + req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { @@ -199,7 +200,8 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, E.New("failed to fetch config, status: ", resp.Status) + respBody, _ := io.ReadAll(resp.Body) + return nil, E.New("failed to fetch config, status: ", resp.Status, ", body: ", string(respBody)) } var result struct { @@ -355,6 +357,7 @@ func (s *Service) pushTraffic(data any) error { req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body)) req.Header.Set("Authorization", s.options.Key) req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { @@ -363,7 +366,8 @@ func (s *Service) pushTraffic(data any) error { defer resp.Body.Close() if resp.StatusCode != 200 { - return E.New("failed to push traffic, status: ", resp.Status) + respBody, _ := io.ReadAll(resp.Body) + return E.New("failed to push traffic, status: ", resp.Status, ", body: ", string(respBody)) } return nil } @@ -388,6 +392,7 @@ func (s *Service) fetchUsers() ([]XUser, error) { url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d", s.options.PanelURL, s.options.NodeID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", s.options.Key) + req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { @@ -396,7 +401,8 @@ func (s *Service) fetchUsers() ([]XUser, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, E.New("failed to fetch users, status: ", resp.Status) + respBody, _ := io.ReadAll(resp.Body) + return nil, E.New("failed to fetch users, status: ", resp.Status, ", body: ", string(respBody)) } var result struct { From 7daa34103517f9ff3193c3a4b86fbfabf7fe539f Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:36:26 +0800 Subject: [PATCH 14/97] =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E6=BC=8F=E6=8E=89?= =?UTF-8?q?=E7=9A=84=20io=20=E5=8C=85=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index 85bdbfbc..98557676 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "sync" "time" From 35ccb885ec36119af290d25994c99895f2ac3df8 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:38:36 +0800 Subject: [PATCH 15/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=88=B0=E8=8A=82=E7=82=B9=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- install.sh | 4 ++++ option/xboard.go | 2 +- service/xboard/service.go | 6 +++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index d0181ca6..7399d34c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# sing-box Xboard Node Configuration PANEL_URL=https://your-panel.com PANEL_TOKEN=your_node_key NODE_ID=1 +NODE_TYPE=v2ray diff --git a/install.sh b/install.sh index dd97da3b..aadb1fe8 100644 --- a/install.sh +++ b/install.sh @@ -126,6 +126,9 @@ PANEL_URL=${INPUT_URL:-$PANEL_URL} read -p "Enter Node ID [${NODE_ID}]: " INPUT_ID NODE_ID=${INPUT_ID:-$NODE_ID} +read -p "Enter Node Type (e.g., v2ray) [${NODE_TYPE:-v2ray}]: " INPUT_TYPE +NODE_TYPE=${INPUT_TYPE:-${NODE_TYPE:-v2ray}} + read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} @@ -167,6 +170,7 @@ cat > "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:40:01 +0800 Subject: [PATCH 16/97] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E4=BA=86=20option/xboa?= =?UTF-8?q?rd.go=E4=B8=AD=E5=A4=9A=E4=BD=99=E7=9A=84=20"encoding/json"=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- option/xboard.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/option/xboard.go b/option/xboard.go index 3073e59f..ed0f7ead 100644 --- a/option/xboard.go +++ b/option/xboard.go @@ -1,8 +1,6 @@ package option import ( - "encoding/json" - "github.com/sagernet/sing/common/json/badoption" ) From 65c0c47a8f1b10e678b08866043d901d91098e00 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:43:25 +0800 Subject: [PATCH 17/97] =?UTF-8?q?=E8=BF=9B=E4=B8=80=E6=AD=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=92=8C=E9=9D=A2=E6=9D=BF=E9=80=9A=E4=BF=A1=E7=9A=84?= =?UTF-8?q?=E6=95=85=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 4a5aafbf..facd1c48 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -189,9 +189,8 @@ func (s *Service) setupNode() error { } func (s *Service) fetchConfig() (*XNodeConfig, error) { - url := fmt.Sprintf("%s/api/v1/server/UniProxy/config?node_id=%d&node_type=%s", s.options.PanelURL, s.options.NodeID, s.options.NodeType) + url := fmt.Sprintf("%s/api/v1/server/UniProxy/config?node_id=%d&node_type=%s&token=%s", s.options.PanelURL, s.options.NodeID, s.options.NodeType, s.options.Key) req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Authorization", s.options.Key) req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) @@ -352,11 +351,10 @@ func (s *Service) reportTraffic() { } func (s *Service) pushTraffic(data any) error { - url := fmt.Sprintf("%s/api/v1/server/UniProxy/push?node_id=%d&node_type=%s", s.options.PanelURL, s.options.NodeID, s.options.NodeType) + url := fmt.Sprintf("%s/api/v1/server/UniProxy/push?node_id=%d&node_type=%s&token=%s", s.options.PanelURL, s.options.NodeID, s.options.NodeType, s.options.Key) body, _ := json.Marshal(data) req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body)) - req.Header.Set("Authorization", s.options.Key) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "sing-box/xboard") @@ -390,9 +388,8 @@ type XUser struct { } func (s *Service) fetchUsers() ([]XUser, error) { - url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d&node_type=%s", s.options.PanelURL, s.options.NodeID, s.options.NodeType) + url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d&node_type=%s&token=%s", s.options.PanelURL, s.options.NodeID, s.options.NodeType, s.options.Key) req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Authorization", s.options.Key) req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) From 950a5539e1772434886e155aab2f09444b4e053d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:45:27 +0800 Subject: [PATCH 18/97] =?UTF-8?q?=E5=90=8C=E4=B8=8A=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 47 +++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index facd1c48..39a62d26 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -44,10 +44,13 @@ type Service struct { } type XNodeConfig struct { - Port int `json:"port"` - Protocol string `json:"protocol"` - Settings json.RawMessage `json:"settings"` - StreamSettings json.RawMessage `json:"streamSettings"` + NodeType string `json:"node_type"` + Config struct { + Port int `json:"port"` + Protocol string `json:"protocol"` + Settings json.RawMessage `json:"settings"` + StreamSettings json.RawMessage `json:"streamSettings"` + } `json:"config"` } type XRealitySettings struct { @@ -117,18 +120,24 @@ func (s *Service) setupNode() error { inboundTag := "xboard-inbound" + protocol := config.Config.Protocol + if protocol == "" { + protocol = config.NodeType + } + s.logger.Info("Xboard protocol identified: ", protocol) + var inboundOptions any - switch config.Protocol { + switch protocol { case "vless": vlessOptions := option.VLESSInboundOptions{ ListenOptions: option.ListenOptions{ - ListenPort: uint16(config.Port), + ListenPort: uint16(config.Config.Port), }, } // Handle Reality var streamSettings XStreamSettings - json.Unmarshal(config.StreamSettings, &streamSettings) + json.Unmarshal(config.Config.StreamSettings, &streamSettings) if streamSettings.Security == "reality" { vlessOptions.TLS = &option.InboundTLSOptions{ Enabled: true, @@ -138,7 +147,7 @@ func (s *Service) setupNode() error { Handshake: option.InboundRealityHandshakeOptions{ ServerOptions: option.ServerOptions{ Server: streamSettings.RealitySettings.Dest, - ServerPort: 443, // Default for most reality setups + ServerPort: 443, }, }, PrivateKey: streamSettings.RealitySettings.PrivateKey, @@ -150,19 +159,33 @@ func (s *Service) setupNode() error { case "vmess": vmessOptions := option.VMessInboundOptions{ ListenOptions: option.ListenOptions{ - ListenPort: uint16(config.Port), + ListenPort: uint16(config.Config.Port), }, } inboundOptions = vmessOptions + case "shadowsocks": + ssOptions := option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + ListenPort: uint16(config.Config.Port), + }, + } + inboundOptions = ssOptions + case "trojan": + trojanOptions := option.TrojanInboundOptions{ + ListenOptions: option.ListenOptions{ + ListenPort: uint16(config.Config.Port), + }, + } + inboundOptions = trojanOptions default: - return fmt.Errorf("unsupported protocol: %s", config.Protocol) + return fmt.Errorf("unsupported protocol: %s", protocol) } // Remove old if exists s.inboundManager.Remove(inboundTag) // Create new inbound - err = s.inboundManager.Create(s.ctx, nil, s.logger, inboundTag, config.Protocol, inboundOptions) + err = s.inboundManager.Create(s.ctx, nil, s.logger, inboundTag, protocol, inboundOptions) if err != nil { return err } @@ -182,7 +205,7 @@ func (s *Service) setupNode() error { s.inboundTags = []string{inboundTag} s.access.Unlock() - s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", config.Port) + s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", config.Config.Port, " (protocol: ", protocol, ")") } return nil From ef1f27c61dd60833d346414131e227f7d635e582 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:54:37 +0800 Subject: [PATCH 19/97] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + api.md | 955 ++++++++++++++++++++++++++++++++++++++ install.sh | 23 + option/xboard.go | 4 + service/xboard/service.go | 79 +++- 5 files changed, 1064 insertions(+), 3 deletions(-) create mode 100644 api.md diff --git a/.env.example b/.env.example index 7399d34c..2975b672 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,9 @@ PANEL_URL=https://your-panel.com PANEL_TOKEN=your_node_key NODE_ID=1 NODE_TYPE=v2ray + +# Separate URLs/IDs (Optional) +# CONFIG_PANEL_URL=https://config-panel.com +# CONFIG_NODE_ID=151 +# USER_PANEL_URL=https://user-panel.com +# USER_NODE_ID=140 diff --git a/api.md b/api.md new file mode 100644 index 00000000..50167aaa --- /dev/null +++ b/api.md @@ -0,0 +1,955 @@ +I made it with AI, but it seems great so far +# Xboard API Documentation + +This document provides a comprehensive overview of all API endpoints available in the Xboard system, organized by access level. + +## API Base URLs + +- **V1 API**: `/api/v1/` +- **V2 API**: `/api/v2/` + +## Authentication + +- **Guest**: No authentication required +- **User**: Requires user authentication (`user` middleware) +- **Staff**: Requires staff privileges (`staff` middleware) +- **Admin**: Requires admin privileges (`admin` middleware) +- **Client**: Requires client authentication (`client` middleware) +- **Server**: Requires server authentication (`server` middleware) + +--- + +## 🌐 Guest APIs (Public - No Authentication Required) + +### Plans +- **GET** `/api/v1/guest/plan/fetch` + - **Purpose**: Get all available subscription plans for public viewing + - **Returns**: List of available plans with pricing, features, and limits + - **Data**: Plan ID, name, prices (monthly/quarterly/yearly), transfer limits, speed limits, device limits, capacity info + +### Configuration +- **GET** `/api/v1/guest/comm/config` + - **Purpose**: Get public configuration settings + - **Returns**: Public app configuration + - **Data**: ToS URL, email verification settings, invite requirements, reCAPTCHA settings, app description, app URL, logo + +### Payment Webhooks +- **GET/POST** `/api/v1/guest/payment/notify/{method}/{uuid}` + - **Purpose**: Handle payment notifications from payment providers + - **Returns**: Payment processing status + - **Data**: Payment confirmation and processing results + +### Telegram Webhooks +- **POST** `/api/v1/guest/telegram/webhook` + - **Purpose**: Handle Telegram bot webhook events + - **Returns**: Webhook processing status + - **Data**: Telegram bot event processing results + +--- + +## 🔐 Authentication APIs (Passport) + +### V1 Authentication +- **POST** `/api/v1/passport/auth/register` + - **Purpose**: User registration + - **Returns**: Registration success/failure + - **Data**: User account creation status + +- **POST** `/api/v1/passport/auth/login` + - **Purpose**: User login + - **Returns**: Authentication token and user info + - **Data**: JWT token, user details, session info + +- **GET** `/api/v1/passport/auth/token2Login` + - **Purpose**: Token-based login + - **Returns**: Login status + - **Data**: Authentication status + +- **POST** `/api/v1/passport/auth/forget` + - **Purpose**: Password reset request + - **Returns**: Reset email status + - **Data**: Password reset confirmation + +- **POST** `/api/v1/passport/auth/getQuickLoginUrl` + - **Purpose**: Generate quick login URL + - **Returns**: Quick login URL + - **Data**: Temporary login URL + +- **POST** `/api/v1/passport/auth/loginWithMailLink` + - **Purpose**: Login via email link + - **Returns**: Login status + - **Data**: Authentication confirmation + +### Communication +- **POST** `/api/v1/passport/comm/sendEmailVerify` + - **Purpose**: Send email verification + - **Returns**: Email sending status + - **Data**: Verification email delivery confirmation + +- **POST** `/api/v1/passport/comm/pv` + - **Purpose**: Page view tracking + - **Returns**: Tracking status + - **Data**: Analytics tracking confirmation + +### V2 Authentication +Same endpoints as V1 but under `/api/v2/passport/` prefix. + +--- + +## 👤 User APIs (Authenticated Users) + +### User Management +- **GET** `/api/v1/user/info` + - **Purpose**: Get current user information + - **Returns**: User profile data + - **Data**: Email, transfer limits, login history, subscription status, balance, commission info, telegram ID, avatar URL + +- **POST** `/api/v1/user/update` + - **Purpose**: Update user preferences + - **Returns**: Update status + - **Data**: Updated user preferences (reminders, etc.) + +- **POST** `/api/v1/user/changePassword` + - **Purpose**: Change user password + - **Returns**: Password change status + - **Data**: Password update confirmation + +- **GET** `/api/v1/user/resetSecurity` + - **Purpose**: Reset security credentials (UUID, token) + - **Returns**: New subscribe URL + - **Data**: New subscription URL with updated token + +- **GET** `/api/v1/user/checkLogin` + - **Purpose**: Check login status + - **Returns**: Login status and permissions + - **Data**: Login status, admin privileges + +- **GET** `/api/v1/user/getStat` + - **Purpose**: Get user statistics + - **Returns**: User statistics summary + - **Data**: Pending orders count, open tickets count, invited users count + +- **GET** `/api/v1/user/getSubscribe` + - **Purpose**: Get subscription information + - **Returns**: Subscription details and URL + - **Data**: Plan details, subscription URL, usage stats, reset schedule + +- **POST** `/api/v1/user/transfer` + - **Purpose**: Transfer commission to balance + - **Returns**: Transfer status + - **Data**: Transfer confirmation and updated balances + +- **POST** `/api/v1/user/getQuickLoginUrl` + - **Purpose**: Generate quick login URL + - **Returns**: Quick login URL + - **Data**: Temporary login URL + +### Session Management +- **GET** `/api/v1/user/getActiveSession` + - **Purpose**: Get active sessions + - **Returns**: List of active sessions + - **Data**: Session details, login times, IP addresses + +- **POST** `/api/v1/user/removeActiveSession` + - **Purpose**: Remove specific session + - **Returns**: Session removal status + - **Data**: Session termination confirmation + +### Orders & Billing +- **POST** `/api/v1/user/order/save` + - **Purpose**: Create new order + - **Returns**: Order creation status + - **Data**: Order details, payment information + +- **POST** `/api/v1/user/order/checkout` + - **Purpose**: Checkout order + - **Returns**: Payment processing info + - **Data**: Payment URL, order status + +- **GET** `/api/v1/user/order/check` + - **Purpose**: Check order status + - **Returns**: Order status + - **Data**: Order processing status + +- **GET** `/api/v1/user/order/detail` + - **Purpose**: Get order details + - **Returns**: Detailed order information + - **Data**: Order items, pricing, status, payment info + +- **GET** `/api/v1/user/order/fetch` + - **Purpose**: Get user's orders + - **Returns**: List of user orders + - **Data**: Order history with status and details + +- **GET** `/api/v1/user/order/getPaymentMethod` + - **Purpose**: Get available payment methods + - **Returns**: List of payment options + - **Data**: Payment providers, fees, availability + +- **POST** `/api/v1/user/order/cancel` + - **Purpose**: Cancel order + - **Returns**: Cancellation status + - **Data**: Order cancellation confirmation + +### Plans +- **GET** `/api/v1/user/plan/fetch` + - **Purpose**: Get available plans for authenticated user + - **Returns**: List of plans with user-specific availability + - **Data**: Plans with pricing, user eligibility, renewal options + +### Invitations +- **GET** `/api/v1/user/invite/save` + - **Purpose**: Generate invitation code + - **Returns**: Invitation code + - **Data**: Invitation code and sharing info + +- **GET** `/api/v1/user/invite/fetch` + - **Purpose**: Get invitation statistics + - **Returns**: Invitation data + - **Data**: Invitation codes, usage stats, commissions + +- **GET** `/api/v1/user/invite/details` + - **Purpose**: Get detailed invitation information + - **Returns**: Invitation details + - **Data**: Detailed invitation statistics and earnings + +### Support & Communication +- **GET** `/api/v1/user/notice/fetch` + - **Purpose**: Get user notices + - **Returns**: List of notices + - **Data**: System announcements, updates, alerts + +- **POST** `/api/v1/user/ticket/save` + - **Purpose**: Create support ticket + - **Returns**: Ticket creation status + - **Data**: Ticket ID and details + +- **GET** `/api/v1/user/ticket/fetch` + - **Purpose**: Get user's tickets + - **Returns**: List of support tickets + - **Data**: Ticket history, status, responses + +- **POST** `/api/v1/user/ticket/reply` + - **Purpose**: Reply to ticket + - **Returns**: Reply status + - **Data**: Reply confirmation + +- **POST** `/api/v1/user/ticket/close` + - **Purpose**: Close ticket + - **Returns**: Closure status + - **Data**: Ticket closure confirmation + +- **POST** `/api/v1/user/ticket/withdraw` + - **Purpose**: Withdraw ticket + - **Returns**: Withdrawal status + - **Data**: Ticket withdrawal confirmation + +### Servers +- **GET** `/api/v1/user/server/fetch` + - **Purpose**: Get available servers + - **Returns**: List of servers user can access + - **Data**: Server details, locations, status, protocols + +### Coupons +- **POST** `/api/v1/user/coupon/check` + - **Purpose**: Validate coupon code + - **Returns**: Coupon validity and discount info + - **Data**: Coupon details, discount amount, validity + +### Telegram Integration +- **GET** `/api/v1/user/telegram/getBotInfo` + - **Purpose**: Get Telegram bot information + - **Returns**: Bot connection info + - **Data**: Bot details, connection status + +### Configuration +- **GET** `/api/v1/user/comm/config` + - **Purpose**: Get user-specific configuration + - **Returns**: User configuration settings + - **Data**: User preferences, feature availability + +- **POST** `/api/v1/user/comm/getStripePublicKey` + - **Purpose**: Get Stripe public key for payments + - **Returns**: Stripe configuration + - **Data**: Stripe public key for payment processing + +### Knowledge Base +- **GET** `/api/v1/user/knowledge/fetch` + - **Purpose**: Get knowledge base articles + - **Returns**: List of help articles + - **Data**: Articles, categories, content + +- **GET** `/api/v1/user/knowledge/getCategory` + - **Purpose**: Get knowledge base categories + - **Returns**: List of categories + - **Data**: Category structure and organization + +### Statistics +- **GET** `/api/v1/user/stat/getTrafficLog` + - **Purpose**: Get traffic usage logs + - **Returns**: Traffic usage history + - **Data**: Traffic logs, usage patterns, timestamps + +### V2 User APIs +- **GET** `/api/v2/user/resetSecurity` + - **Purpose**: Reset security credentials + - **Returns**: New security credentials + - **Data**: Updated security tokens + +- **GET** `/api/v2/user/info` + - **Purpose**: Get user information (V2) + - **Returns**: User profile data + - **Data**: Enhanced user profile information + +--- + +## 📱 Client APIs (Client Authentication) + +### Subscription +- **GET** `/api/v1/client/subscribe` + - **Purpose**: Get subscription configuration + - **Returns**: Client configuration for VPN apps + - **Data**: Server configurations, protocols, connection details + +### App Configuration +- **GET** `/api/v1/client/app/getConfig` + - **Purpose**: Get app configuration + - **Returns**: App configuration settings + - **Data**: App settings, features, URLs + +- **GET** `/api/v1/client/app/getVersion` + - **Purpose**: Get app version information + - **Returns**: Version details + - **Data**: Current version, update availability + +--- + +## 🖥️ Server APIs (Server Authentication) + +### UniProxy +- **GET** `/api/v1/server/UniProxy/config` + - **Purpose**: Get server configuration + - **Returns**: Server configuration + - **Data**: Server settings and parameters + +- **GET** `/api/v1/server/UniProxy/user` + - **Purpose**: Get user data for server + - **Returns**: User information for server + - **Data**: User access rights, quotas, settings + +- **POST** `/api/v1/server/UniProxy/push` + - **Purpose**: Push data to server + - **Returns**: Push status + - **Data**: Data synchronization confirmation + +- **POST** `/api/v1/server/UniProxy/alive` + - **Purpose**: Server heartbeat + - **Returns**: Alive status + - **Data**: Server health status + +- **GET** `/api/v1/server/UniProxy/alivelist` + - **Purpose**: Get alive servers list + - **Returns**: List of active servers + - **Data**: Server status and availability + +- **POST** `/api/v1/server/UniProxy/status` + - **Purpose**: Update server status + - **Returns**: Status update confirmation + - **Data**: Server status update + +### Shadowsocks Tidalab +- **GET** `/api/v1/server/ShadowsocksTidalab/user` + - **Purpose**: Get user data for Shadowsocks + - **Returns**: Shadowsocks user configuration + - **Data**: Shadowsocks-specific user settings + +- **POST** `/api/v1/server/ShadowsocksTidalab/submit` + - **Purpose**: Submit Shadowsocks data + - **Returns**: Submission status + - **Data**: Data submission confirmation + +### Trojan Tidalab +- **GET** `/api/v1/server/TrojanTidalab/config` + - **Purpose**: Get Trojan server configuration + - **Returns**: Trojan configuration + - **Data**: Trojan server settings + +- **GET** `/api/v1/server/TrojanTidalab/user` + - **Purpose**: Get user data for Trojan + - **Returns**: Trojan user configuration + - **Data**: Trojan-specific user settings + +- **POST** `/api/v1/server/TrojanTidalab/submit` + - **Purpose**: Submit Trojan data + - **Returns**: Submission status + - **Data**: Data submission confirmation + +--- + +## 👨‍💼 Staff APIs (Staff Authentication) + +**Note**: Staff functionality exists but appears to be integrated into admin routes rather than having dedicated staff routes. Staff users have `is_staff` flag and can access certain admin functions with limited permissions. + +### User Management (Staff Level) +- Staff can manage non-admin, non-staff users +- Limited user update capabilities +- Send emails to users +- Ban users +- Access user information + +--- + +## 🔧 Admin APIs (Administrator Authentication) + +### Configuration Management +- **GET** `/api/v2/{admin_path}/config/fetch` + - **Purpose**: Get system configuration + - **Returns**: Complete system settings + - **Data**: All configuration parameters + +- **POST** `/api/v2/{admin_path}/config/save` + - **Purpose**: Save system configuration + - **Returns**: Save status + - **Data**: Configuration update confirmation + +- **GET** `/api/v2/{admin_path}/config/getEmailTemplate` + - **Purpose**: Get email templates + - **Returns**: Email template configurations + - **Data**: Email templates and settings + +- **GET** `/api/v2/{admin_path}/config/getThemeTemplate` + - **Purpose**: Get theme templates + - **Returns**: Theme configurations + - **Data**: Available themes and settings + +- **POST** `/api/v2/{admin_path}/config/setTelegramWebhook` + - **Purpose**: Configure Telegram webhook + - **Returns**: Webhook setup status + - **Data**: Telegram integration status + +- **POST** `/api/v2/{admin_path}/config/testSendMail` + - **Purpose**: Test email configuration + - **Returns**: Email test results + - **Data**: Email sending test status + +### Plan Management +- **GET** `/api/v2/{admin_path}/plan/fetch` + - **Purpose**: Get all plans (admin view) + - **Returns**: Complete plan list with admin details + - **Data**: All plans with user counts, revenue, admin settings + +- **POST** `/api/v2/{admin_path}/plan/save` + - **Purpose**: Create/update plan + - **Returns**: Plan save status + - **Data**: Plan creation/update confirmation + +- **POST** `/api/v2/{admin_path}/plan/drop` + - **Purpose**: Delete plan + - **Returns**: Deletion status + - **Data**: Plan deletion confirmation + +- **POST** `/api/v2/{admin_path}/plan/update` + - **Purpose**: Update plan details + - **Returns**: Update status + - **Data**: Plan update confirmation + +- **POST** `/api/v2/{admin_path}/plan/sort` + - **Purpose**: Reorder plans + - **Returns**: Sort status + - **Data**: Plan ordering confirmation + +### Server Management +#### Server Groups +- **GET** `/api/v2/{admin_path}/server/group/fetch` + - **Purpose**: Get server groups + - **Returns**: List of server groups + - **Data**: Group configurations and permissions + +- **POST** `/api/v2/{admin_path}/server/group/save` + - **Purpose**: Create/update server group + - **Returns**: Group save status + - **Data**: Group creation/update confirmation + +- **POST** `/api/v2/{admin_path}/server/group/drop` + - **Purpose**: Delete server group + - **Returns**: Deletion status + - **Data**: Group deletion confirmation + +#### Server Routes +- **GET** `/api/v2/{admin_path}/server/route/fetch` + - **Purpose**: Get server routes + - **Returns**: List of server routes + - **Data**: Route configurations and rules + +- **POST** `/api/v2/{admin_path}/server/route/save` + - **Purpose**: Create/update server route + - **Returns**: Route save status + - **Data**: Route creation/update confirmation + +- **POST** `/api/v2/{admin_path}/server/route/drop` + - **Purpose**: Delete server route + - **Returns**: Deletion status + - **Data**: Route deletion confirmation + +#### Server Management +- **GET** `/api/v2/{admin_path}/server/manage/getNodes` + - **Purpose**: Get server nodes + - **Returns**: List of server nodes + - **Data**: Node details, status, configuration + +- **POST** `/api/v2/{admin_path}/server/manage/update` + - **Purpose**: Update server + - **Returns**: Update status + - **Data**: Server update confirmation + +- **POST** `/api/v2/{admin_path}/server/manage/save` + - **Purpose**: Create server + - **Returns**: Creation status + - **Data**: Server creation confirmation + +- **POST** `/api/v2/{admin_path}/server/manage/drop` + - **Purpose**: Delete server + - **Returns**: Deletion status + - **Data**: Server deletion confirmation + +- **POST** `/api/v2/{admin_path}/server/manage/copy` + - **Purpose**: Copy server configuration + - **Returns**: Copy status + - **Data**: Server copy confirmation + +- **POST** `/api/v2/{admin_path}/server/manage/sort` + - **Purpose**: Reorder servers + - **Returns**: Sort status + - **Data**: Server ordering confirmation + +### Order Management +- **GET/POST** `/api/v2/{admin_path}/order/fetch` + - **Purpose**: Get orders with filtering + - **Returns**: Paginated order list + - **Data**: Order details, user info, payment status + +- **POST** `/api/v2/{admin_path}/order/update` + - **Purpose**: Update order + - **Returns**: Update status + - **Data**: Order update confirmation + +- **POST** `/api/v2/{admin_path}/order/assign` + - **Purpose**: Assign order to plan + - **Returns**: Assignment status + - **Data**: Order assignment confirmation + +- **POST** `/api/v2/{admin_path}/order/paid` + - **Purpose**: Mark order as paid + - **Returns**: Payment status + - **Data**: Payment confirmation + +- **POST** `/api/v2/{admin_path}/order/cancel` + - **Purpose**: Cancel order + - **Returns**: Cancellation status + - **Data**: Order cancellation confirmation + +- **POST** `/api/v2/{admin_path}/order/detail` + - **Purpose**: Get order details + - **Returns**: Detailed order information + - **Data**: Complete order information + +### User Management +- **GET/POST** `/api/v2/{admin_path}/user/fetch` + - **Purpose**: Get users with filtering and pagination + - **Returns**: Paginated user list + - **Data**: User details, subscription info, usage stats + +- **POST** `/api/v2/{admin_path}/user/update` + - **Purpose**: Update user + - **Returns**: Update status + - **Data**: User update confirmation + +- **GET** `/api/v2/{admin_path}/user/getUserInfoById` + - **Purpose**: Get specific user info + - **Returns**: User details + - **Data**: Complete user information + +- **POST** `/api/v2/{admin_path}/user/generate` + - **Purpose**: Generate user account + - **Returns**: Generation status + - **Data**: New user account details + +- **POST** `/api/v2/{admin_path}/user/dumpCSV` + - **Purpose**: Export users to CSV + - **Returns**: CSV export + - **Data**: User data in CSV format + +- **POST** `/api/v2/{admin_path}/user/sendMail` + - **Purpose**: Send email to users + - **Returns**: Email sending status + - **Data**: Email delivery confirmation + +- **POST** `/api/v2/{admin_path}/user/ban` + - **Purpose**: Ban users + - **Returns**: Ban status + - **Data**: User ban confirmation + +- **POST** `/api/v2/{admin_path}/user/resetSecret` + - **Purpose**: Reset user secrets + - **Returns**: Reset status + - **Data**: Secret reset confirmation + +- **POST** `/api/v2/{admin_path}/user/setInviteUser` + - **Purpose**: Set invite relationships + - **Returns**: Setting status + - **Data**: Invite relationship confirmation + +- **POST** `/api/v2/{admin_path}/user/destroy` + - **Purpose**: Delete user + - **Returns**: Deletion status + - **Data**: User deletion confirmation + +### Statistics & Analytics +- **GET** `/api/v2/{admin_path}/stat/getOverride` + - **Purpose**: Get system overview + - **Returns**: System statistics overview + - **Data**: Key metrics, revenue, user counts + +- **GET** `/api/v2/{admin_path}/stat/getStats` + - **Purpose**: Get detailed statistics + - **Returns**: Comprehensive statistics + - **Data**: Revenue, usage, growth metrics + +- **GET** `/api/v2/{admin_path}/stat/getServerLastRank` + - **Purpose**: Get server performance ranking + - **Returns**: Server ranking data + - **Data**: Server performance metrics + +- **GET** `/api/v2/{admin_path}/stat/getServerYesterdayRank` + - **Purpose**: Get yesterday's server ranking + - **Returns**: Historical server data + - **Data**: Previous day server metrics + +- **GET** `/api/v2/{admin_path}/stat/getOrder` + - **Purpose**: Get order statistics + - **Returns**: Order analytics + - **Data**: Order trends, revenue data + +- **GET/POST** `/api/v2/{admin_path}/stat/getStatUser` + - **Purpose**: Get user statistics + - **Returns**: User analytics + - **Data**: User growth, activity metrics + +- **GET** `/api/v2/{admin_path}/stat/getRanking` + - **Purpose**: Get ranking data + - **Returns**: Various rankings + - **Data**: User, server, revenue rankings + +- **GET** `/api/v2/{admin_path}/stat/getStatRecord` + - **Purpose**: Get statistical records + - **Returns**: Historical statistics + - **Data**: Historical data records + +- **GET** `/api/v2/{admin_path}/stat/getTrafficRank` + - **Purpose**: Get traffic ranking + - **Returns**: Traffic usage rankings + - **Data**: Traffic usage by users/servers + +### Notice Management +- **GET** `/api/v2/{admin_path}/notice/fetch` + - **Purpose**: Get system notices + - **Returns**: List of notices + - **Data**: Notice content, visibility, scheduling + +- **POST** `/api/v2/{admin_path}/notice/save` + - **Purpose**: Create notice + - **Returns**: Creation status + - **Data**: Notice creation confirmation + +- **POST** `/api/v2/{admin_path}/notice/update` + - **Purpose**: Update notice + - **Returns**: Update status + - **Data**: Notice update confirmation + +- **POST** `/api/v2/{admin_path}/notice/drop` + - **Purpose**: Delete notice + - **Returns**: Deletion status + - **Data**: Notice deletion confirmation + +- **POST** `/api/v2/{admin_path}/notice/show` + - **Purpose**: Toggle notice visibility + - **Returns**: Visibility status + - **Data**: Notice visibility confirmation + +- **POST** `/api/v2/{admin_path}/notice/sort` + - **Purpose**: Reorder notices + - **Returns**: Sort status + - **Data**: Notice ordering confirmation + +### Ticket Management +- **GET/POST** `/api/v2/{admin_path}/ticket/fetch` + - **Purpose**: Get support tickets + - **Returns**: Paginated ticket list + - **Data**: Ticket details, user info, status + +- **POST** `/api/v2/{admin_path}/ticket/reply` + - **Purpose**: Reply to ticket + - **Returns**: Reply status + - **Data**: Ticket reply confirmation + +- **POST** `/api/v2/{admin_path}/ticket/close` + - **Purpose**: Close ticket + - **Returns**: Closure status + - **Data**: Ticket closure confirmation + +### Coupon Management +- **GET/POST** `/api/v2/{admin_path}/coupon/fetch` + - **Purpose**: Get coupons + - **Returns**: List of coupons + - **Data**: Coupon details, usage statistics + +- **POST** `/api/v2/{admin_path}/coupon/generate` + - **Purpose**: Generate coupons + - **Returns**: Generation status + - **Data**: New coupon codes + +- **POST** `/api/v2/{admin_path}/coupon/drop` + - **Purpose**: Delete coupon + - **Returns**: Deletion status + - **Data**: Coupon deletion confirmation + +- **POST** `/api/v2/{admin_path}/coupon/show` + - **Purpose**: Toggle coupon visibility + - **Returns**: Visibility status + - **Data**: Coupon visibility confirmation + +- **POST** `/api/v2/{admin_path}/coupon/update` + - **Purpose**: Update coupon + - **Returns**: Update status + - **Data**: Coupon update confirmation + +### Knowledge Base Management +- **GET** `/api/v2/{admin_path}/knowledge/fetch` + - **Purpose**: Get knowledge articles + - **Returns**: List of articles + - **Data**: Article content, categories, visibility + +- **GET** `/api/v2/{admin_path}/knowledge/getCategory` + - **Purpose**: Get knowledge categories + - **Returns**: Category structure + - **Data**: Category hierarchy and organization + +- **POST** `/api/v2/{admin_path}/knowledge/save` + - **Purpose**: Create/update article + - **Returns**: Save status + - **Data**: Article save confirmation + +- **POST** `/api/v2/{admin_path}/knowledge/show` + - **Purpose**: Toggle article visibility + - **Returns**: Visibility status + - **Data**: Article visibility confirmation + +- **POST** `/api/v2/{admin_path}/knowledge/drop` + - **Purpose**: Delete article + - **Returns**: Deletion status + - **Data**: Article deletion confirmation + +- **POST** `/api/v2/{admin_path}/knowledge/sort` + - **Purpose**: Reorder articles + - **Returns**: Sort status + - **Data**: Article ordering confirmation + +### Payment Management +- **GET** `/api/v2/{admin_path}/payment/fetch` + - **Purpose**: Get payment methods + - **Returns**: List of payment providers + - **Data**: Payment method configurations + +- **GET** `/api/v2/{admin_path}/payment/getPaymentMethods` + - **Purpose**: Get available payment methods + - **Returns**: Payment method list + - **Data**: Available payment options + +- **POST** `/api/v2/{admin_path}/payment/getPaymentForm` + - **Purpose**: Get payment form configuration + - **Returns**: Form configuration + - **Data**: Payment form settings + +- **POST** `/api/v2/{admin_path}/payment/save` + - **Purpose**: Save payment method + - **Returns**: Save status + - **Data**: Payment method save confirmation + +- **POST** `/api/v2/{admin_path}/payment/drop` + - **Purpose**: Delete payment method + - **Returns**: Deletion status + - **Data**: Payment method deletion confirmation + +- **POST** `/api/v2/{admin_path}/payment/show` + - **Purpose**: Toggle payment method visibility + - **Returns**: Visibility status + - **Data**: Payment method visibility confirmation + +- **POST** `/api/v2/{admin_path}/payment/sort` + - **Purpose**: Reorder payment methods + - **Returns**: Sort status + - **Data**: Payment method ordering confirmation + +### System Management +- **GET** `/api/v2/{admin_path}/system/getSystemStatus` + - **Purpose**: Get system status + - **Returns**: System health information + - **Data**: Server status, performance metrics + +- **GET** `/api/v2/{admin_path}/system/getQueueStats` + - **Purpose**: Get queue statistics + - **Returns**: Queue performance data + - **Data**: Queue metrics, job statistics + +- **GET** `/api/v2/{admin_path}/system/getQueueWorkload` + - **Purpose**: Get queue workload + - **Returns**: Current queue workload + - **Data**: Queue load and processing times + +- **GET** `/api/v2/{admin_path}/system/getQueueMasters` + - **Purpose**: Get queue masters (Horizon) + - **Returns**: Queue master status + - **Data**: Horizon supervisor information + +- **GET** `/api/v2/{admin_path}/system/getSystemLog` + - **Purpose**: Get system logs + - **Returns**: System log entries + - **Data**: Application logs, errors, events + +- **GET** `/api/v2/{admin_path}/system/getHorizonFailedJobs` + - **Purpose**: Get failed jobs + - **Returns**: Failed job list + - **Data**: Failed job details and errors + +- **POST** `/api/v2/{admin_path}/system/clearSystemLog` + - **Purpose**: Clear system logs + - **Returns**: Clear status + - **Data**: Log clearing confirmation + +- **GET** `/api/v2/{admin_path}/system/getLogClearStats` + - **Purpose**: Get log clearing statistics + - **Returns**: Log management stats + - **Data**: Log storage and clearing metrics + +### Theme Management +- **GET** `/api/v2/{admin_path}/theme/getThemes` + - **Purpose**: Get available themes + - **Returns**: Theme list + - **Data**: Theme details, configurations + +- **POST** `/api/v2/{admin_path}/theme/upload` + - **Purpose**: Upload theme + - **Returns**: Upload status + - **Data**: Theme upload confirmation + +- **POST** `/api/v2/{admin_path}/theme/delete` + - **Purpose**: Delete theme + - **Returns**: Deletion status + - **Data**: Theme deletion confirmation + +- **POST** `/api/v2/{admin_path}/theme/saveThemeConfig` + - **Purpose**: Save theme configuration + - **Returns**: Save status + - **Data**: Theme configuration save confirmation + +- **POST** `/api/v2/{admin_path}/theme/getThemeConfig` + - **Purpose**: Get theme configuration + - **Returns**: Theme configuration + - **Data**: Current theme settings + +### Plugin Management +- **GET** `/api/v2/{admin_path}/plugin/getPlugins` + - **Purpose**: Get installed plugins + - **Returns**: Plugin list + - **Data**: Plugin details, status, configuration + +- **POST** `/api/v2/{admin_path}/plugin/upload` + - **Purpose**: Upload plugin + - **Returns**: Upload status + - **Data**: Plugin upload confirmation + +- **POST** `/api/v2/{admin_path}/plugin/delete` + - **Purpose**: Delete plugin + - **Returns**: Deletion status + - **Data**: Plugin deletion confirmation + +- **POST** `/api/v2/{admin_path}/plugin/install` + - **Purpose**: Install plugin + - **Returns**: Installation status + - **Data**: Plugin installation confirmation + +- **POST** `/api/v2/{admin_path}/plugin/uninstall` + - **Purpose**: Uninstall plugin + - **Returns**: Uninstallation status + - **Data**: Plugin uninstallation confirmation + +- **POST** `/api/v2/{admin_path}/plugin/enable` + - **Purpose**: Enable plugin + - **Returns**: Enable status + - **Data**: Plugin enable confirmation + +- **POST** `/api/v2/{admin_path}/plugin/disable` + - **Purpose**: Disable plugin + - **Returns**: Disable status + - **Data**: Plugin disable confirmation + +- **GET** `/api/v2/{admin_path}/plugin/config` + - **Purpose**: Get plugin configuration + - **Returns**: Plugin configuration + - **Data**: Plugin settings + +- **POST** `/api/v2/{admin_path}/plugin/config` + - **Purpose**: Update plugin configuration + - **Returns**: Update status + - **Data**: Plugin configuration update confirmation + +### Traffic Reset Management +- **GET** `/api/v2/{admin_path}/traffic-reset/logs` + - **Purpose**: Get traffic reset logs + - **Returns**: Reset log entries + - **Data**: Traffic reset history and details + +- **GET** `/api/v2/{admin_path}/traffic-reset/stats` + - **Purpose**: Get traffic reset statistics + - **Returns**: Reset statistics + - **Data**: Traffic reset metrics and trends + +- **GET** `/api/v2/{admin_path}/traffic-reset/user/{userId}/history` + - **Purpose**: Get user traffic reset history + - **Returns**: User-specific reset history + - **Data**: Individual user reset records + +- **POST** `/api/v2/{admin_path}/traffic-reset/reset-user` + - **Purpose**: Reset user traffic + - **Returns**: Reset status + - **Data**: User traffic reset confirmation + +--- + +## 📝 Notes + +1. **Admin Path**: The `{admin_path}` in V2 admin routes is dynamically generated based on the `secure_path` or `frontend_admin_path` configuration setting. + +2. **Authentication Middleware**: + - `user`: Requires valid user authentication + - `admin`: Requires admin privileges + - `staff`: Requires staff privileges + - `client`: Requires client token authentication + - `server`: Requires server authentication + +3. **Response Format**: Most endpoints return data in a standardized format: + ```json + { + "data": [...], // Actual response data + "message": "Success message", + "status": true/false + } + ``` + +4. **Error Handling**: Failed requests return appropriate HTTP status codes with error messages. + +5. **Pagination**: Many list endpoints support pagination with `current` and `pageSize` parameters. + +6. **Filtering**: Admin endpoints often support filtering and sorting parameters. + +This documentation provides a comprehensive overview of all available API endpoints in the Xboard system. Each endpoint serves specific functionality within the VPN service management platform. \ No newline at end of file diff --git a/install.sh b/install.sh index aadb1fe8..b3537eed 100644 --- a/install.sh +++ b/install.sh @@ -132,6 +132,25 @@ NODE_TYPE=${INPUT_TYPE:-${NODE_TYPE:-v2ray}} read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} +# Optional Separate Config/User IDs and URLs +read -p "Use separate URL/ID for Config? (y/N): " USE_SEP_CONFIG +if [[ "$USE_SEP_CONFIG" =~ ^[Yy]$ ]]; then + read -p " Enter Config Panel URL [${CONFIG_PANEL_URL:-$PANEL_URL}]: " INPUT_CURL + CONFIG_PANEL_URL=${INPUT_CURL:-$PANEL_URL} + CONFIG_PANEL_URL="${CONFIG_PANEL_URL%/}" + read -p " Enter Config Node ID [${CONFIG_NODE_ID:-$NODE_ID}]: " INPUT_CID + CONFIG_NODE_ID=${INPUT_CID:-$NODE_ID} +fi + +read -p "Use separate URL/ID for Users? (y/N): " USE_SEP_USER +if [[ "$USE_SEP_USER" =~ ^[Yy]$ ]]; then + read -p " Enter User Panel URL [${USER_PANEL_URL:-$PANEL_URL}]: " INPUT_UURL + USER_PANEL_URL=${INPUT_UURL:-$PANEL_URL} + USER_PANEL_URL="${USER_PANEL_URL%/}" + read -p " Enter User Node ID [${USER_NODE_ID:-$NODE_ID}]: " INPUT_UID + USER_NODE_ID=${INPUT_UID:-$NODE_ID} +fi + if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then echo -e "${RED}All fields are required!${NC}" exit 1 @@ -168,9 +187,13 @@ cat > "$CONFIG_FILE" < Date: Tue, 14 Apr 2026 23:56:42 +0800 Subject: [PATCH 20/97] =?UTF-8?q?=E9=B2=81=E6=A3=92=E6=80=A7=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 111 ++++++++++++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 14b734b4..f86f0600 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -68,16 +68,59 @@ type XNodeConfig struct { } type XRealitySettings struct { - Dest string `json:"dest"` - ServerNames []string `json:"serverNames"` - PrivateKey string `json:"privateKey"` - ShortId string `json:"shortId"` + Dest string `json:"dest"` + ServerNames []string `json:"serverNames"` + ServerNames_ []string `json:"server_names"` + PrivateKey string `json:"privateKey"` + PrivateKey_ string `json:"private_key"` + ShortId string `json:"shortId"` + ShortId_ string `json:"short_id"` + ShortIds []string `json:"shortIds"` + ShortIds_ []string `json:"short_ids"` +} + +func (r *XRealitySettings) GetPrivateKey() string { + if r.PrivateKey != "" { + return r.PrivateKey + } + return r.PrivateKey_ +} + +func (r *XRealitySettings) GetShortIds() []string { + if len(r.ShortIds) > 0 { + return r.ShortIds + } + if len(r.ShortIds_) > 0 { + return r.ShortIds_ + } + if r.ShortId != "" { + return []string{r.ShortId} + } + if r.ShortId_ != "" { + return []string{r.ShortId_} + } + return nil +} + +func (r *XRealitySettings) GetServerNames() []string { + if len(r.ServerNames) > 0 { + return r.ServerNames + } + return r.ServerNames_ } type XStreamSettings struct { Network string `json:"network"` Security string `json:"security"` RealitySettings XRealitySettings `json:"realitySettings"` + RealitySettings_ XRealitySettings `json:"reality_settings"` +} + +func (s *XStreamSettings) GetReality() *XRealitySettings { + if s.RealitySettings.GetPrivateKey() != "" { + return &s.RealitySettings + } + return &s.RealitySettings_ } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) { @@ -155,20 +198,26 @@ func (s *Service) setupNode() error { // Handle Reality var streamSettings XStreamSettings json.Unmarshal(config.Config.StreamSettings, &streamSettings) - if streamSettings.Security == "reality" { + reality := streamSettings.GetReality() + if streamSettings.Security == "reality" && reality != nil { + serverNames := reality.GetServerNames() + serverName := "" + if len(serverNames) > 0 { + serverName = serverNames[0] + } vlessOptions.TLS = &option.InboundTLSOptions{ Enabled: true, - ServerName: streamSettings.RealitySettings.ServerNames[0], + ServerName: serverName, Reality: &option.InboundRealityOptions{ Enabled: true, Handshake: option.InboundRealityHandshakeOptions{ ServerOptions: option.ServerOptions{ - Server: streamSettings.RealitySettings.Dest, + Server: reality.Dest, ServerPort: 443, }, }, - PrivateKey: streamSettings.RealitySettings.PrivateKey, - ShortID: badoption.Listable[string]{streamSettings.RealitySettings.ShortId}, + PrivateKey: reality.GetPrivateKey(), + ShortID: badoption.Listable[string](reality.GetShortIds()), }, } } @@ -252,11 +301,14 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { return nil, E.New("failed to fetch config, status: ", resp.Status, ", body: ", string(respBody)) } + body, _ := io.ReadAll(resp.Body) + var result struct { Data XNodeConfig `json:"data"` } - err = json.NewDecoder(resp.Body).Decode(&result) + err = json.Unmarshal(body, &result) if err != nil { + s.logger.Debug("Xboard raw config response: ", string(body)) return nil, err } return &result.Data, nil @@ -289,7 +341,7 @@ type userData struct { func (s *Service) syncUsers() { s.logger.Info("Xboard sync users...") - xUsers, err := s.fetchUsers() + users, err := s.fetchUsers() if err != nil { s.logger.Error("Xboard sync error: ", err) return @@ -299,11 +351,8 @@ func (s *Service) syncUsers() { defer s.access.Unlock() newUsers := make(map[string]userData) - for _, u := range xUsers { - key := u.UUID - if key == "" { - key = u.Passwd - } + for _, u := range users { + key := u.ResolveKey() if key == "" { continue } @@ -468,11 +517,26 @@ func (s *Service) Close() error { // Xboard User Model type XUser struct { - ID int `json:"id"` - Email string `json:"email"` - UUID string `json:"uuid"` - Passwd string `json:"passwd"` - Flow string `json:"flow"` + ID int `json:"id"` + Email string `json:"email"` + UUID string `json:"uuid"` // V2ray/Vless + Passwd string `json:"passwd"` // SS + Password string `json:"password"` // Trojan/SS alternate + Token string `json:"token"` // Alternate + Flow string `json:"flow"` +} + +func (u *XUser) ResolveKey() string { + if u.UUID != "" { + return u.UUID + } + if u.Passwd != "" { + return u.Passwd + } + if u.Password != "" { + return u.Password + } + return u.Token } func (s *Service) fetchUsers() ([]XUser, error) { @@ -499,11 +563,14 @@ func (s *Service) fetchUsers() ([]XUser, error) { return nil, E.New("failed to fetch users, status: ", resp.Status, ", body: ", string(respBody)) } + body, _ := io.ReadAll(resp.Body) + var result struct { Data []XUser `json:"data"` } - err = json.NewDecoder(resp.Body).Decode(&result) + err = json.Unmarshal(body, &result) if err != nil { + s.logger.Debug("Xboard raw user response: ", string(body)) return nil, err } return result.Data, nil From dad60c6ce98938ac3c4b37ddfff0f5c4d05401cb Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 23:58:14 +0800 Subject: [PATCH 21/97] =?UTF-8?q?=E7=AC=AC=20386=20=E8=A1=8C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=89=93=E5=8D=B0=E4=B8=AD=E7=9A=84=20xUsers=20?= =?UTF-8?q?=E6=94=B9=E5=9B=9E=E4=BA=86=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index f86f0600..97c17243 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -383,7 +383,7 @@ func (s *Service) syncUsers() { // Update local ID mapping s.localUsers = newUsers - s.logger.Info("Xboard sync completed, total users: ", len(xUsers)) + s.logger.Info("Xboard sync completed, total users: ", len(users)) } func (s *Service) reportTraffic() { From 76a656bf07a47998903533d1192ccfc3dfdc4ecd Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:00:39 +0800 Subject: [PATCH 22/97] =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E5=86=85=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 81 ++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 97c17243..d211640c 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -58,13 +58,22 @@ type XBoardServiceOptions struct { } type XNodeConfig struct { - NodeType string `json:"node_type"` - Config struct { - Port int `json:"port"` - Protocol string `json:"protocol"` - Settings json.RawMessage `json:"settings"` - StreamSettings json.RawMessage `json:"streamSettings"` - } `json:"config"` + NodeType string `json:"node_type"` + NodeType_ string `json:"nodeType"` + ServerConfig json.RawMessage `json:"server_config"` + ServerConfig_ json.RawMessage `json:"serverConfig"` + Config json.RawMessage `json:"config"` + Port int `json:"port"` + Protocol string `json:"protocol"` + Settings json.RawMessage `json:"settings"` + StreamSettings json.RawMessage `json:"streamSettings"` +} + +type XInnerConfig struct { + Port int `json:"port"` + Protocol string `json:"protocol"` + Settings json.RawMessage `json:"settings"` + StreamSettings json.RawMessage `json:"streamSettings"` } type XRealitySettings struct { @@ -180,10 +189,43 @@ func (s *Service) setupNode() error { inboundTag := "xboard-inbound" - protocol := config.Config.Protocol + // Resolve nested config + var inner XInnerConfig + if len(config.ServerConfig) > 0 { + json.Unmarshal(config.ServerConfig, &inner) + } else if len(config.ServerConfig_) > 0 { + json.Unmarshal(config.ServerConfig_, &inner) + } else if len(config.Config) > 0 { + json.Unmarshal(config.Config, &inner) + } + + // Fallback to flat if still empty + if inner.Protocol == "" { + inner.Protocol = config.Protocol + } + if inner.Port == 0 { + inner.Port = config.Port + } + if inner.Settings == nil { + inner.Settings = config.Settings + } + if inner.StreamSettings == nil { + inner.StreamSettings = config.StreamSettings + } + + protocol := inner.Protocol if protocol == "" { protocol = config.NodeType } + if protocol == "" { + protocol = config.NodeType_ + } + + if protocol == "" { + s.logger.Error("Xboard setup error: could not identify protocol. Please check debug logs for raw JSON.") + return fmt.Errorf("unsupported protocol: empty") + } + s.logger.Info("Xboard protocol identified: ", protocol) var inboundOptions any @@ -191,13 +233,13 @@ func (s *Service) setupNode() error { case "vless": vlessOptions := option.VLESSInboundOptions{ ListenOptions: option.ListenOptions{ - ListenPort: uint16(config.Config.Port), + ListenPort: uint16(inner.Port), }, } // Handle Reality var streamSettings XStreamSettings - json.Unmarshal(config.Config.StreamSettings, &streamSettings) + json.Unmarshal(inner.StreamSettings, &streamSettings) reality := streamSettings.GetReality() if streamSettings.Security == "reality" && reality != nil { serverNames := reality.GetServerNames() @@ -225,21 +267,21 @@ func (s *Service) setupNode() error { case "vmess": vmessOptions := option.VMessInboundOptions{ ListenOptions: option.ListenOptions{ - ListenPort: uint16(config.Config.Port), + ListenPort: uint16(inner.Port), }, } inboundOptions = vmessOptions case "shadowsocks": ssOptions := option.ShadowsocksInboundOptions{ ListenOptions: option.ListenOptions{ - ListenPort: uint16(config.Config.Port), + ListenPort: uint16(inner.Port), }, } inboundOptions = ssOptions case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ - ListenPort: uint16(config.Config.Port), + ListenPort: uint16(inner.Port), }, } inboundOptions = trojanOptions @@ -255,6 +297,12 @@ func (s *Service) setupNode() error { if err != nil { return err } + + s.access.Lock() + s.inboundTags = []string{inboundTag} + s.access.Unlock() + + s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", inner.Port, " (protocol: ", protocol, ")") // Register the new inbound in our managed list inbound, _ := s.inboundManager.Get(inboundTag) @@ -308,9 +356,16 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { } err = json.Unmarshal(body, &result) if err != nil { + s.logger.Debug("Xboard decoder error: ", err) s.logger.Debug("Xboard raw config response: ", string(body)) return nil, err } + + // Final safety check: if everything we need is empty, log it anyway + if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" { + s.logger.Debug("Xboard config mapping failed. Raw response: ", string(body)) + } + return &result.Data, nil } From 5f8750e91696c0b8928c1cb69234d6d05ec2fce9 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:01:24 +0800 Subject: [PATCH 23/97] =?UTF-8?q?=E5=B0=86=E7=AC=AC=20319=20=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=AD=E7=9A=84=20config.Config.Port=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BA=86=E5=B7=B2=E7=BB=8F=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=A5=BD=E7=9A=84=20inner.Port=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index d211640c..d96565c3 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -319,7 +319,7 @@ func (s *Service) setupNode() error { s.inboundTags = []string{inboundTag} s.access.Unlock() - s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", config.Config.Port, " (protocol: ", protocol, ")") + s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", inner.Port, " (protocol: ", protocol, ")") } return nil From be4d0c3ac070506e26f387439f9af52474afdc69 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:04:58 +0800 Subject: [PATCH 24/97] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E7=BC=96=E8=AF=91=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 41 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index d96565c3..9b267367 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -64,16 +64,23 @@ type XNodeConfig struct { ServerConfig_ json.RawMessage `json:"serverConfig"` Config json.RawMessage `json:"config"` Port int `json:"port"` + ServerPort int `json:"server_port"` Protocol string `json:"protocol"` Settings json.RawMessage `json:"settings"` StreamSettings json.RawMessage `json:"streamSettings"` + // Shadowsocks / generic root fields + Cipher string `json:"cipher"` + ServerKey string `json:"server_key"` } type XInnerConfig struct { Port int `json:"port"` + ServerPort int `json:"server_port"` Protocol string `json:"protocol"` Settings json.RawMessage `json:"settings"` StreamSettings json.RawMessage `json:"streamSettings"` + Cipher string `json:"cipher"` + ServerKey string `json:"server_key"` } type XRealitySettings struct { @@ -204,13 +211,17 @@ func (s *Service) setupNode() error { inner.Protocol = config.Protocol } if inner.Port == 0 { - inner.Port = config.Port + if config.Port != 0 { + inner.Port = config.Port + } else { + inner.Port = config.ServerPort + } } - if inner.Settings == nil { - inner.Settings = config.Settings + if inner.Cipher == "" { + inner.Cipher = config.Cipher } - if inner.StreamSettings == nil { - inner.StreamSettings = config.StreamSettings + if inner.ServerKey == "" { + inner.ServerKey = config.ServerKey } protocol := inner.Protocol @@ -276,6 +287,8 @@ func (s *Service) setupNode() error { ListenOptions: option.ListenOptions{ ListenPort: uint16(inner.Port), }, + Method: inner.Cipher, + Password: inner.ServerKey, } inboundOptions = ssOptions case "trojan": @@ -355,15 +368,21 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { Data XNodeConfig `json:"data"` } err = json.Unmarshal(body, &result) - if err != nil { - s.logger.Debug("Xboard decoder error: ", err) - s.logger.Debug("Xboard raw config response: ", string(body)) + if err != nil || (result.Data.Protocol == "" && result.Data.NodeType == "" && result.Data.ServerPort == 0) { + // Try unmarshaling WITHOUT "data" wrapper + var flatResult XNodeConfig + if err2 := json.Unmarshal(body, &flatResult); err2 == nil { + return &flatResult, nil + } + + s.logger.Error("Xboard decoder error: ", err) + s.logger.Error("Xboard raw config response: ", string(body)) return nil, err } - // Final safety check: if everything we need is empty, log it anyway - if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" { - s.logger.Debug("Xboard config mapping failed. Raw response: ", string(body)) + // Final safety check + if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" && result.Data.ServerPort == 0 { + s.logger.Error("Xboard config mapping failed (fields missing). Data: ", string(body)) } return &result.Data, nil From ee7300435af72ce50a4d30fa53a0d0bb3461d53b Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:06:39 +0800 Subject: [PATCH 25/97] =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E9=83=BD=E5=8A=A0=E4=B8=8A=E4=BA=86=E5=8F=96=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E7=AC=A6=20&?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 9b267367..35895340 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -274,14 +274,14 @@ func (s *Service) setupNode() error { }, } } - inboundOptions = vlessOptions + inboundOptions = &vlessOptions case "vmess": vmessOptions := option.VMessInboundOptions{ ListenOptions: option.ListenOptions{ ListenPort: uint16(inner.Port), }, } - inboundOptions = vmessOptions + inboundOptions = &vmessOptions case "shadowsocks": ssOptions := option.ShadowsocksInboundOptions{ ListenOptions: option.ListenOptions{ @@ -290,14 +290,14 @@ func (s *Service) setupNode() error { Method: inner.Cipher, Password: inner.ServerKey, } - inboundOptions = ssOptions + inboundOptions = &ssOptions case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ ListenPort: uint16(inner.Port), }, } - inboundOptions = trojanOptions + inboundOptions = &trojanOptions default: return fmt.Errorf("unsupported protocol: %s", protocol) } From 035a1335a8ed4b9f61dbd4d78cb39167c4f74aaa Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:08:57 +0800 Subject: [PATCH 26/97] 1 --- service/xboard/service.go | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 35895340..3695a456 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -63,6 +63,7 @@ type XNodeConfig struct { ServerConfig json.RawMessage `json:"server_config"` ServerConfig_ json.RawMessage `json:"serverConfig"` Config json.RawMessage `json:"config"` + ListenIP string `json:"listen_ip"` Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` @@ -74,6 +75,7 @@ type XNodeConfig struct { } type XInnerConfig struct { + ListenIP string `json:"listen_ip"` Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` @@ -207,6 +209,13 @@ func (s *Service) setupNode() error { } // Fallback to flat if still empty + if inner.ListenIP == "" { + inner.ListenIP = config.ListenIP + } + if inner.ListenIP == "" { + inner.ListenIP = "0.0.0.0" + } + if inner.Protocol == "" { inner.Protocol = config.Protocol } @@ -239,11 +248,14 @@ func (s *Service) setupNode() error { s.logger.Info("Xboard protocol identified: ", protocol) + listenAddr := badoption.ParseAddr(inner.ListenIP) + var inboundOptions any switch protocol { case "vless": vlessOptions := option.VLESSInboundOptions{ ListenOptions: option.ListenOptions{ + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, } @@ -278,6 +290,7 @@ func (s *Service) setupNode() error { case "vmess": vmessOptions := option.VMessInboundOptions{ ListenOptions: option.ListenOptions{ + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, } @@ -285,6 +298,7 @@ func (s *Service) setupNode() error { case "shadowsocks": ssOptions := option.ShadowsocksInboundOptions{ ListenOptions: option.ListenOptions{ + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, Method: inner.Cipher, @@ -294,6 +308,7 @@ func (s *Service) setupNode() error { case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, } @@ -421,6 +436,10 @@ func (s *Service) syncUsers() { return } + if len(users) == 0 { + s.logger.Warn("Xboard sync: no users returned from panel. Check your Node ID and User config.") + } + s.access.Lock() defer s.access.Unlock() @@ -640,12 +659,19 @@ func (s *Service) fetchUsers() ([]XUser, error) { body, _ := io.ReadAll(resp.Body) var result struct { - Data []XUser `json:"data"` + Data []XUser `json:"data"` + Users []XUser `json:"users"` } err = json.Unmarshal(body, &result) if err != nil { - s.logger.Debug("Xboard raw user response: ", string(body)) + s.logger.Error("Xboard raw user response: ", string(body)) return nil, err } - return result.Data, nil + + userList := result.Data + if len(userList) == 0 { + userList = result.Users + } + + return userList, nil } From 1f168f144e6369132832406beb77e445e6ad756a Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:10:44 +0800 Subject: [PATCH 27/97] =?UTF-8?q?=E8=AF=86=E5=88=AB=E6=82=A8=E7=9A=84?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 3695a456..14e45b11 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/netip" "sync" "time" @@ -16,6 +17,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/ssmapi" + "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/service" @@ -248,14 +250,19 @@ func (s *Service) setupNode() error { s.logger.Info("Xboard protocol identified: ", protocol) - listenAddr := badoption.ParseAddr(inner.ListenIP) + var listenAddr *badoption.Addr + if addr, err := netip.ParseAddr(inner.ListenIP); err == nil { + listenAddr = common.Ptr(badoption.Addr(addr)) + } else { + listenAddr = common.Ptr(badoption.Addr(netip.IPv4Unspecified())) + } var inboundOptions any switch protocol { case "vless": vlessOptions := option.VLESSInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: &listenAddr, + Listen: listenAddr, ListenPort: uint16(inner.Port), }, } @@ -290,7 +297,7 @@ func (s *Service) setupNode() error { case "vmess": vmessOptions := option.VMessInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: &listenAddr, + Listen: listenAddr, ListenPort: uint16(inner.Port), }, } @@ -298,7 +305,7 @@ func (s *Service) setupNode() error { case "shadowsocks": ssOptions := option.ShadowsocksInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: &listenAddr, + Listen: listenAddr, ListenPort: uint16(inner.Port), }, Method: inner.Cipher, @@ -308,7 +315,7 @@ func (s *Service) setupNode() error { case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: &listenAddr, + Listen: listenAddr, ListenPort: uint16(inner.Port), }, } From 5d5aefedfdb46f262eb2db6f7b6786a2262f70dc Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:14:56 +0800 Subject: [PATCH 28/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dblack2022=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 14e45b11..c82ef6c4 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,6 +3,8 @@ package xboard import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io" @@ -23,6 +25,19 @@ import ( "github.com/sagernet/sing/service" ) +func fixSSKey(key string, length int) string { + if key == "" { + return "" + } + // Try to decode as-is first + if data, err := base64.StdEncoding.DecodeString(key); err == nil && len(data) == length { + return key + } + // Use SHA256 to derive a fixed-length key from the password + hash := sha256.Sum256([]byte(key)) + return base64.StdEncoding.EncodeToString(hash[:length]) +} + func RegisterService(registry *boxService.Registry) { boxService.Register[option.XBoardServiceOptions](registry, C.TypeXBoard, NewService) } @@ -303,13 +318,26 @@ func (s *Service) setupNode() error { } inboundOptions = &vmessOptions case "shadowsocks": + method := inner.Cipher + serverKey := inner.ServerKey + if method == "" { + method = "aes-256-gcm" + } + // Hardening for Shadowsocks 2022 + if len(method) >= 4 && method[:4] == "2022" { + keyLen := 32 + if len(method) >= 18 && method[13:16] == "128" { + keyLen = 16 + } + serverKey = fixSSKey(serverKey, keyLen) + } ssOptions := option.ShadowsocksInboundOptions{ ListenOptions: option.ListenOptions{ Listen: listenAddr, ListenPort: uint16(inner.Port), }, - Method: inner.Cipher, - Password: inner.ServerKey, + Method: method, + Password: serverKey, } inboundOptions = &ssOptions case "trojan": From 17ed1bbe21d678cb77e5e2c74b8fd67a4c35b8e6 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:20:34 +0800 Subject: [PATCH 29/97] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=B92022=E7=9A=84=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 25 ++++++++----------------- service/xboard/service.go | 30 ++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/install.sh b/install.sh index b3537eed..a53b6657 100644 --- a/install.sh +++ b/install.sh @@ -132,24 +132,15 @@ NODE_TYPE=${INPUT_TYPE:-${NODE_TYPE:-v2ray}} read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} -# Optional Separate Config/User IDs and URLs -read -p "Use separate URL/ID for Config? (y/N): " USE_SEP_CONFIG -if [[ "$USE_SEP_CONFIG" =~ ^[Yy]$ ]]; then - read -p " Enter Config Panel URL [${CONFIG_PANEL_URL:-$PANEL_URL}]: " INPUT_CURL - CONFIG_PANEL_URL=${INPUT_CURL:-$PANEL_URL} - CONFIG_PANEL_URL="${CONFIG_PANEL_URL%/}" - read -p " Enter Config Node ID [${CONFIG_NODE_ID:-$NODE_ID}]: " INPUT_CID - CONFIG_NODE_ID=${INPUT_CID:-$NODE_ID} -fi +# Consolidation +CONFIG_PANEL_URL=$PANEL_URL +CONFIG_NODE_ID=$NODE_ID +USER_PANEL_URL=$PANEL_URL +USER_NODE_ID=$NODE_ID -read -p "Use separate URL/ID for Users? (y/N): " USE_SEP_USER -if [[ "$USE_SEP_USER" =~ ^[Yy]$ ]]; then - read -p " Enter User Panel URL [${USER_PANEL_URL:-$PANEL_URL}]: " INPUT_UURL - USER_PANEL_URL=${INPUT_UURL:-$PANEL_URL} - USER_PANEL_URL="${USER_PANEL_URL%/}" - read -p " Enter User Node ID [${USER_NODE_ID:-$NODE_ID}]: " INPUT_UID - USER_NODE_ID=${INPUT_UID:-$NODE_ID} -fi +# Sync time (Critical for SS 2022) +echo -e "${YELLOW}Syncing system time...${NC}" +timedatectl set-ntp true || true if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then echo -e "${RED}All fields are required!${NC}" diff --git a/service/xboard/service.go b/service/xboard/service.go index c82ef6c4..0c0a305e 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -29,13 +29,24 @@ func fixSSKey(key string, length int) string { if key == "" { return "" } - // Try to decode as-is first + // If it's already a valid Base64 of the correct length, use it directly (as a B64 key) if data, err := base64.StdEncoding.DecodeString(key); err == nil && len(data) == length { return key } - // Use SHA256 to derive a fixed-length key from the password - hash := sha256.Sum256([]byte(key)) - return base64.StdEncoding.EncodeToString(hash[:length]) + + // Legacy repetition/truncation logic for raw string keys (common in Xboard/V2board) + password := key + if len(password) > length { + password = password[:length] + } + for len(password) < length { + password += password + } + // Re-truncate after repetition + password = password[:length] + + // Convert to Base64 so sing-box treats it as a raw byte key + return base64.StdEncoding.EncodeToString([]byte(password)) } func RegisterService(registry *boxService.Registry) { @@ -406,6 +417,17 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { return nil, err } defer resp.Body.Close() + + // Check time drift + if dateStr := resp.Header.Get("Date"); dateStr != "" { + if panelTime, err := http.ParseTime(dateStr); err == nil { + drift := time.Since(panelTime) + if drift < 0 { drift = -drift } + if drift > 30*time.Second { + s.logger.Error("CRITICAL TIME DRIFT: ", drift, " off from Panel. SS 2022 WILL FAIL. Sync your time!") + } + } + } if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) From 3c8615221abf2b0f8aa40c0d20c7b862f9df6baa Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:21:54 +0800 Subject: [PATCH 30/97] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E7=AC=AC=206?= =?UTF-8?q?=20=E8=A1=8C=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20crypto/sha25?= =?UTF-8?q?6=20=E5=AF=BC=E5=85=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 0c0a305e..4a4e90ea 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,7 +3,6 @@ package xboard import ( "bytes" "context" - "crypto/sha256" "encoding/base64" "encoding/json" "fmt" From 16aef305730748159aee59008b6f4e17b10e1552 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:24:49 +0800 Subject: [PATCH 31/97] 32 --- service/xboard/service.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 4a4e90ea..057e7968 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,6 +3,7 @@ package xboard import ( "bytes" "context" + "crypto/md5" "encoding/base64" "encoding/json" "fmt" @@ -33,19 +34,17 @@ func fixSSKey(key string, length int) string { return key } - // Legacy repetition/truncation logic for raw string keys (common in Xboard/V2board) - password := key - if len(password) > length { - password = password[:length] - } - for len(password) < length { - password += password - } - // Re-truncate after repetition - password = password[:length] + // Xboard style for Shadowsocks 2022: Base64(MD5_Hex(password)) + // 32 hex characters happen to be exactly 32 bytes of ASCII, perfect for aes-256-gcm + hash := md5.Sum([]byte(key)) + hexHash := fmt.Sprintf("%x", hash) - // Convert to Base64 so sing-box treats it as a raw byte key - return base64.StdEncoding.EncodeToString([]byte(password)) + // For 128-bit methods, truncate the hex string to 16 + if length == 16 && len(hexHash) > 16 { + hexHash = hexHash[:16] + } + + return base64.StdEncoding.EncodeToString([]byte(hexHash)) } func RegisterService(registry *boxService.Registry) { From fef3ac71ea02b9a8a42e65c62825a8af6cea240e Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:27:13 +0800 Subject: [PATCH 32/97] ds --- service/xboard/service.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index 057e7968..62bb7e4b 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -329,6 +329,9 @@ func (s *Service) setupNode() error { case "shadowsocks": method := inner.Cipher serverKey := inner.ServerKey + if serverKey == "" { + serverKey = s.options.Key + } if method == "" { method = "aes-256-gcm" } From a2fbf89828112a3f07912cb97e2bca377f5ed4c2 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:33:15 +0800 Subject: [PATCH 33/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E7=BC=96=E8=AF=91=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 68 +++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 62bb7e4b..7b9dbfbd 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -93,11 +93,11 @@ type XNodeConfig struct { Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` - Settings json.RawMessage `json:"settings"` - StreamSettings json.RawMessage `json:"streamSettings"` - // Shadowsocks / generic root fields - Cipher string `json:"cipher"` - ServerKey string `json:"server_key"` + Cipher string `json:"cipher"` + ServerKey string `json:"server_key"` + TLS int `json:"tls"` + Flow string `json:"flow"` + TLSSettings *XTLSSettings `json:"tls_settings"` } type XInnerConfig struct { @@ -109,6 +109,19 @@ type XInnerConfig struct { StreamSettings json.RawMessage `json:"streamSettings"` Cipher string `json:"cipher"` ServerKey string `json:"server_key"` + TLS int `json:"tls"` + Flow string `json:"flow"` + TLSSettings *XTLSSettings `json:"tls_settings"` +} + +type XTLSSettings struct { + ServerName string `json:"server_name"` + ServerPort string `json:"server_port"` + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + ShortID string `json:"short_id"` + ShortIDs []string `json:"short_ids"` + AllowInsecure bool `json:"allow_insecure"` } type XRealitySettings struct { @@ -242,6 +255,16 @@ func (s *Service) setupNode() error { inner.ListenIP = "0.0.0.0" } + if inner.TLSSettings == nil { + inner.TLSSettings = config.TLSSettings + } + if inner.TLS == 0 { + inner.TLS = config.TLS + } + if inner.Flow == "" { + inner.Flow = config.Flow + } + if inner.Protocol == "" { inner.Protocol = config.Protocol } @@ -274,11 +297,36 @@ func (s *Service) setupNode() error { s.logger.Info("Xboard protocol identified: ", protocol) - var listenAddr *badoption.Addr + var listenAddr badoption.Addr if addr, err := netip.ParseAddr(inner.ListenIP); err == nil { - listenAddr = common.Ptr(badoption.Addr(addr)) + listenAddr = badoption.Addr(addr) } else { - listenAddr = common.Ptr(badoption.Addr(netip.IPv4Unspecified())) + listenAddr = badoption.Addr(netip.IPv4Unspecified()) + } + + var tlsOptions *option.InboundTLSOptions + if inner.TLS > 0 && inner.TLSSettings != nil { + tlsOptions = &option.InboundTLSOptions{ + Enabled: true, + ServerName: inner.TLSSettings.ServerName, + } + if inner.TLS == 2 { // Reality + shortIDs := inner.TLSSettings.ShortIDs + if len(shortIDs) == 0 && inner.TLSSettings.ShortID != "" { + shortIDs = []string{inner.TLSSettings.ShortID} + } + tlsOptions.Reality = &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: inner.TLSSettings.ServerName, + ServerPort: 443, + }, + }, + PrivateKey: inner.TLSSettings.PrivateKey, + ShortID: badoption.Listable[string](shortIDs), + } + } } var inboundOptions any @@ -286,9 +334,11 @@ func (s *Service) setupNode() error { case "vless": vlessOptions := option.VLESSInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: listenAddr, + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, + TLS: tlsOptions, + Flow: inner.Flow, } // Handle Reality From eb7773e9e6c7a9a19b5f6b35f03ccb1ac10588aa Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:35:07 +0800 Subject: [PATCH 34/97] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 7b9dbfbd..ca346222 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -371,7 +371,7 @@ func (s *Service) setupNode() error { case "vmess": vmessOptions := option.VMessInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: listenAddr, + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, } @@ -395,7 +395,7 @@ func (s *Service) setupNode() error { } ssOptions := option.ShadowsocksInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: listenAddr, + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, Method: method, @@ -405,7 +405,7 @@ func (s *Service) setupNode() error { case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ - Listen: listenAddr, + Listen: &listenAddr, ListenPort: uint16(inner.Port), }, } From d9b55fd04cc1d417be1cd08e0901f92c651af96d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:36:40 +0800 Subject: [PATCH 35/97] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=86=97=E4=BD=99?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index ca346222..86f8dba6 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -19,7 +19,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/ssmapi" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/service" @@ -337,8 +336,9 @@ func (s *Service) setupNode() error { Listen: &listenAddr, ListenPort: uint16(inner.Port), }, - TLS: tlsOptions, - Flow: inner.Flow, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: tlsOptions, + }, } // Handle Reality From d48015df6b7903444c909bbf194345688cb593c2 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:39:51 +0800 Subject: [PATCH 36/97] =?UTF-8?q?VLESS=20=E5=AE=89=E5=85=A8=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 57 +++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 86f8dba6..a3aee1a0 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -331,40 +331,37 @@ func (s *Service) setupNode() error { var inboundOptions any switch protocol { case "vless": - vlessOptions := option.VLESSInboundOptions{ - ListenOptions: option.ListenOptions{ - Listen: &listenAddr, - ListenPort: uint16(inner.Port), - }, - InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ - TLS: tlsOptions, - }, - } + vlessOptions := option.VLESSInboundOptions{} + vlessOptions.Listen = &listenAddr + vlessOptions.ListenPort = uint16(inner.Port) + vlessOptions.TLS = tlsOptions // Handle Reality - var streamSettings XStreamSettings - json.Unmarshal(inner.StreamSettings, &streamSettings) - reality := streamSettings.GetReality() - if streamSettings.Security == "reality" && reality != nil { - serverNames := reality.GetServerNames() - serverName := "" - if len(serverNames) > 0 { - serverName = serverNames[0] - } - vlessOptions.TLS = &option.InboundTLSOptions{ - Enabled: true, - ServerName: serverName, - Reality: &option.InboundRealityOptions{ - Enabled: true, - Handshake: option.InboundRealityHandshakeOptions{ - ServerOptions: option.ServerOptions{ - Server: reality.Dest, - ServerPort: 443, + if inner.StreamSettings != nil { + var streamSettings XStreamSettings + json.Unmarshal(inner.StreamSettings, &streamSettings) + reality := streamSettings.GetReality() + if streamSettings.Security == "reality" && reality != nil { + serverNames := reality.GetServerNames() + serverName := "" + if len(serverNames) > 0 { + serverName = serverNames[0] + } + vlessOptions.TLS = &option.InboundTLSOptions{ + Enabled: true, + ServerName: serverName, + Reality: &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: reality.Dest, + ServerPort: 443, + }, }, + PrivateKey: reality.GetPrivateKey(), + ShortID: badoption.Listable[string](reality.GetShortIds()), }, - PrivateKey: reality.GetPrivateKey(), - ShortID: badoption.Listable[string](reality.GetShortIds()), - }, + } } } inboundOptions = &vlessOptions From a0da3d7cb6a983b8ea7a9fa4ef13b180bdbb6c44 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:42:43 +0800 Subject: [PATCH 37/97] =?UTF-8?q?=E7=BA=A7=E5=AF=86=E9=92=A5=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index a3aee1a0..19aae5ce 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -325,6 +325,10 @@ func (s *Service) setupNode() error { PrivateKey: inner.TLSSettings.PrivateKey, ShortID: badoption.Listable[string](shortIDs), } + // Fallback if empty + if tlsOptions.Reality.Handshake.Server == "" { + tlsOptions.Reality.Handshake.Server = "www.microsoft.com" + } } } @@ -362,6 +366,7 @@ func (s *Service) setupNode() error { ShortID: badoption.Listable[string](reality.GetShortIds()), }, } + s.logger.Info("Xboard REALITY config from streamSettings. PrivateKey preview: ", reality.GetPrivateKey()[:4], "...") } } inboundOptions = &vlessOptions @@ -399,6 +404,7 @@ func (s *Service) setupNode() error { Password: serverKey, } inboundOptions = &ssOptions + s.logger.Info("Xboard Shadowsocks 2022 setup. Method: ", method, " Password preview: ", serverKey[:8], "...") case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ @@ -549,11 +555,22 @@ func (s *Service) syncUsers() { defer s.access.Unlock() newUsers := make(map[string]userData) + isSS2022 := false + if s.options.NodeType == "shadowsocks" { + isSS2022 = true + } + for _, u := range users { key := u.ResolveKey() if key == "" { continue } + + // CRITICAL: Apply MD5-Hex hardening to each USER key for SS 2022 + if isSS2022 { + key = fixSSKey(key, 32) + } + newUsers[u.Email] = userData{ ID: u.ID, Email: u.Email, From 2013f31776724bd083e331fea298468a61300bca Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 00:45:25 +0800 Subject: [PATCH 38/97] =?UTF-8?q?=E6=B5=8B=E8=AF=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 19aae5ce..52dc6613 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -403,8 +403,11 @@ func (s *Service) setupNode() error { Method: method, Password: serverKey, } + + // If no colon is used in client, we might need a fallback. + // We'll leave it to be updated dynamically when users sync. inboundOptions = &ssOptions - s.logger.Info("Xboard Shadowsocks 2022 setup. Method: ", method, " Password preview: ", serverKey[:8], "...") + s.logger.Info("Xboard Shadowsocks 2022 setup. Method: ", method, " PSK preview: ", serverKey[:8], "...") case "trojan": trojanOptions := option.TrojanInboundOptions{ ListenOptions: option.ListenOptions{ @@ -475,10 +478,12 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { // Check time drift if dateStr := resp.Header.Get("Date"); dateStr != "" { if panelTime, err := http.ParseTime(dateStr); err == nil { - drift := time.Since(panelTime) + localTime := time.Now() + drift := localTime.Sub(panelTime) if drift < 0 { drift = -drift } + s.logger.Info("TIME CHECK: Panel Local [", panelTime.Format(time.RFC3339), "] vs Server Local [", localTime.Format(time.RFC3339), "]. Drift: ", drift) if drift > 30*time.Second { - s.logger.Error("CRITICAL TIME DRIFT: ", drift, " off from Panel. SS 2022 WILL FAIL. Sync your time!") + s.logger.Error("CRITICAL TIME DRIFT: Your server time is OUT OF SYNC. Shadowsocks 2022 WILL FAIL!") } } } @@ -598,6 +603,20 @@ func (s *Service) syncUsers() { // Update local ID mapping s.localUsers = newUsers + // Compatibility Hack: For SS 2022 without colon, + // we may need the first user's key as the global PSK + if isSS2022 && len(newUsers) > 0 { + for _, u := range newUsers { + for _, managedServer := range s.servers { + if ssInbound, ok := managedServer.(interface{ SetPassword(string) }); ok { + ssInbound.SetPassword(u.Key) + s.logger.Info("Compatibility Mode: Set global PSK to user key: ", u.Key[:8], "...") + } + } + break // Only use the first one as fallback + } + } + s.logger.Info("Xboard sync completed, total users: ", len(users)) } From be0f2fc89156d7a873c9da25d5e359d7cd442434 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:07:55 +0800 Subject: [PATCH 39/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E4=BD=BF=E7=94=A8=E7=9A=84=E4=B8=A5=E9=87=8D?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 56 ++-- service/xboard/service.go | 522 ++++++++++++++++++++++++++++++-------- 2 files changed, 447 insertions(+), 131 deletions(-) diff --git a/.gitignore b/.gitignore index d2b74d08..fbdb6a58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,35 @@ -/.idea/ -/vendor/ -/*.json -/*.srs -/*.db -/site/ -/bin/ -/dist/ -/sing-box -/sing-box.exe -/build/ -/*.jar -/*.aar -/*.xcframework/ -/experimental/libbox/*.aar -/experimental/libbox/*.xcframework/ -/experimental/libbox/*.nupkg +# Binaries +sing-box +sing-box.exe +*.exe +*.dll +*.so +*.dylib + +# Environment +.env +.env.local + +# Build & Cache +go.sum +bin/ +dist/ +/var/lib/sing-box/* + +# Logs +*.log +/var/log/sing-box/* + +# OS .DS_Store -/config.d/ -/venv/ -CLAUDE.md -AGENTS.md -/.claude/ +Thumbs.db + +# Antigravity/Gemini Artifacts +.gemini/ +artifacts/ +scratch/ +implementation_plan*.md +walkthrough*.md +task.md + +V2bX/ diff --git a/service/xboard/service.go b/service/xboard/service.go index 52dc6613..f57c2aee 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -10,6 +10,8 @@ import ( "io" "net/http" "net/netip" + "net/url" + "strconv" "sync" "time" @@ -83,34 +85,87 @@ type XBoardServiceOptions struct { } type XNodeConfig struct { - NodeType string `json:"node_type"` - NodeType_ string `json:"nodeType"` - ServerConfig json.RawMessage `json:"server_config"` - ServerConfig_ json.RawMessage `json:"serverConfig"` - Config json.RawMessage `json:"config"` - ListenIP string `json:"listen_ip"` - Port int `json:"port"` - ServerPort int `json:"server_port"` - Protocol string `json:"protocol"` - Cipher string `json:"cipher"` - ServerKey string `json:"server_key"` - TLS int `json:"tls"` - Flow string `json:"flow"` - TLSSettings *XTLSSettings `json:"tls_settings"` -} - -type XInnerConfig struct { + NodeType string `json:"node_type"` + NodeType_ string `json:"nodeType"` + ServerConfig json.RawMessage `json:"server_config"` + ServerConfig_ json.RawMessage `json:"serverConfig"` + Config json.RawMessage `json:"config"` ListenIP string `json:"listen_ip"` Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` - Settings json.RawMessage `json:"settings"` - StreamSettings json.RawMessage `json:"streamSettings"` Cipher string `json:"cipher"` ServerKey string `json:"server_key"` TLS int `json:"tls"` Flow string `json:"flow"` TLSSettings *XTLSSettings `json:"tls_settings"` + TLSSettings_ *XTLSSettings `json:"tlsSettings"` + Network string `json:"network"` + NetworkSettings json.RawMessage `json:"network_settings"` + NetworkSettings_ json.RawMessage `json:"networkSettings"` + + // Hysteria / Hysteria2 + UpMbps int `json:"up_mbps"` + DownMbps int `json:"down_mbps"` + Obfs string `json:"obfs"` + ObfsPassword string `json:"obfs-password"` + Ignore_Client_Bandwidth bool `json:"ignore_client_bandwidth"` + + // Tuic + CongestionControl string `json:"congestion_control"` + ZeroRTTHandshake bool `json:"zero_rtt_handshake"` + + // AnyTls + PaddingScheme []string `json:"padding_scheme"` +} + +type XInnerConfig struct { + ListenIP string `json:"listen_ip"` + Port int `json:"port"` + ServerPort int `json:"server_port"` + Protocol string `json:"protocol"` + Settings json.RawMessage `json:"settings"` + StreamSettings json.RawMessage `json:"streamSettings"` + Cipher string `json:"cipher"` + ServerKey string `json:"server_key"` + TLS int `json:"tls"` + Flow string `json:"flow"` + TLSSettings *XTLSSettings `json:"tls_settings"` + TLSSettings_ *XTLSSettings `json:"tlsSettings"` + Network string `json:"network"` + NetworkSettings json.RawMessage `json:"network_settings"` + NetworkSettings_ json.RawMessage `json:"networkSettings"` +} + +type HttpNetworkConfig struct { + Header struct { + Type string `json:"type"` + Request *json.RawMessage `json:"request"` + Response *json.RawMessage `json:"response"` + } `json:"header"` +} + +type HttpRequest struct { + Version string `json:"version"` + Method string `json:"method"` + Path []string `json:"path"` + Headers struct { + Host []string `json:"Host"` + } `json:"headers"` +} + +type WsNetworkConfig struct { + Path string `json:"path"` + Headers map[string]string `json:"headers"` +} + +type GrpcNetworkConfig struct { + ServiceName string `json:"serviceName"` +} + +type HttpupgradeNetworkConfig struct { + Path string `json:"path"` + Host string `json:"host"` } type XTLSSettings struct { @@ -121,6 +176,7 @@ type XTLSSettings struct { ShortID string `json:"short_id"` ShortIDs []string `json:"short_ids"` AllowInsecure bool `json:"allow_insecure"` + Dest string `json:"dest"` } type XRealitySettings struct { @@ -227,6 +283,99 @@ func (s *Service) Start(stage adapter.StartStage) error { return nil } +func getInboundTransport(network string, settings json.RawMessage) (*option.V2RayTransportOptions, error) { + if network == "" { + return nil, nil + } + t := &option.V2RayTransportOptions{ + Type: network, + } + switch network { + case "tcp": + if len(settings) != 0 { + var networkConfig HttpNetworkConfig + err := json.Unmarshal(settings, &networkConfig) + if err != nil { + return nil, fmt.Errorf("decode NetworkSettings error: %s", err) + } + if networkConfig.Header.Type == "http" { + t.Type = networkConfig.Header.Type + if networkConfig.Header.Request != nil { + var request HttpRequest + err = json.Unmarshal(*networkConfig.Header.Request, &request) + if err != nil { + return nil, fmt.Errorf("decode HttpRequest error: %s", err) + } + t.HTTPOptions.Host = request.Headers.Host + if len(request.Path) > 0 { + t.HTTPOptions.Path = request.Path[0] + } + t.HTTPOptions.Method = request.Method + } + } else { + t.Type = "" + } + } else { + t.Type = "" + } + case "ws": + var ( + path string + ed int + headers map[string]badoption.Listable[string] + ) + if len(settings) != 0 { + var networkConfig WsNetworkConfig + err := json.Unmarshal(settings, &networkConfig) + if err != nil { + return nil, fmt.Errorf("decode NetworkSettings error: %s", err) + } + u, err := url.Parse(networkConfig.Path) + if err != nil { + return nil, fmt.Errorf("parse WS path error: %s", err) + } + path = u.Path + ed, _ = strconv.Atoi(u.Query().Get("ed")) + if len(networkConfig.Headers) > 0 { + headers = make(map[string]badoption.Listable[string], len(networkConfig.Headers)) + for k, v := range networkConfig.Headers { + headers[k] = badoption.Listable[string]{v} + } + } + } + t.WebsocketOptions = option.V2RayWebsocketOptions{ + Path: path, + EarlyDataHeaderName: "Sec-WebSocket-Protocol", + MaxEarlyData: uint32(ed), + Headers: headers, + } + case "grpc": + if len(settings) != 0 { + var networkConfig GrpcNetworkConfig + err := json.Unmarshal(settings, &networkConfig) + if err != nil { + return nil, fmt.Errorf("decode gRPC settings error: %s", err) + } + t.GRPCOptions = option.V2RayGRPCOptions{ + ServiceName: networkConfig.ServiceName, + } + } + case "httpupgrade": + if len(settings) != 0 { + var networkConfig HttpupgradeNetworkConfig + err := json.Unmarshal(settings, &networkConfig) + if err != nil { + return nil, fmt.Errorf("decode HttpUpgrade settings error: %s", err) + } + t.HTTPUpgradeOptions = option.V2RayHTTPUpgradeOptions{ + Path: networkConfig.Path, + Host: networkConfig.Host, + } + } + } + return t, nil +} + func (s *Service) setupNode() error { s.logger.Info("Xboard fetching node config...") config, err := s.fetchConfig() @@ -235,8 +384,8 @@ func (s *Service) setupNode() error { } inboundTag := "xboard-inbound" - - // Resolve nested config + + // Resolve nested config (V2bX compatibility: server_config / serverConfig / config) var inner XInnerConfig if len(config.ServerConfig) > 0 { json.Unmarshal(config.ServerConfig, &inner) @@ -245,25 +394,29 @@ func (s *Service) setupNode() error { } else if len(config.Config) > 0 { json.Unmarshal(config.Config, &inner) } - - // Fallback to flat if still empty + + // Fallback flat fields from top-level config to inner if inner.ListenIP == "" { inner.ListenIP = config.ListenIP } if inner.ListenIP == "" { inner.ListenIP = "0.0.0.0" } - if inner.TLSSettings == nil { inner.TLSSettings = config.TLSSettings } + if inner.TLSSettings == nil { + inner.TLSSettings = config.TLSSettings_ + } + if inner.TLSSettings_ == nil { + inner.TLSSettings_ = config.TLSSettings_ + } if inner.TLS == 0 { inner.TLS = config.TLS } if inner.Flow == "" { inner.Flow = config.Flow } - if inner.Protocol == "" { inner.Protocol = config.Protocol } @@ -280,7 +433,17 @@ func (s *Service) setupNode() error { if inner.ServerKey == "" { inner.ServerKey = config.ServerKey } + if inner.Network == "" { + inner.Network = config.Network + } + if len(inner.NetworkSettings) == 0 { + inner.NetworkSettings = config.NetworkSettings + } + if len(inner.NetworkSettings_) == 0 { + inner.NetworkSettings_ = config.NetworkSettings_ + } + // Resolve protocol protocol := inner.Protocol if protocol == "" { protocol = config.NodeType @@ -288,12 +451,11 @@ func (s *Service) setupNode() error { if protocol == "" { protocol = config.NodeType_ } - if protocol == "" { s.logger.Error("Xboard setup error: could not identify protocol. Please check debug logs for raw JSON.") return fmt.Errorf("unsupported protocol: empty") } - + s.logger.Info("Xboard protocol identified: ", protocol) var listenAddr badoption.Addr @@ -302,82 +464,134 @@ func (s *Service) setupNode() error { } else { listenAddr = badoption.Addr(netip.IPv4Unspecified()) } - - var tlsOptions *option.InboundTLSOptions - if inner.TLS > 0 && inner.TLSSettings != nil { - tlsOptions = &option.InboundTLSOptions{ - Enabled: true, - ServerName: inner.TLSSettings.ServerName, + + listen := option.ListenOptions{ + Listen: &listenAddr, + ListenPort: uint16(inner.Port), + } + + // ── TLS / Reality handling (matching V2bX panel.Security constants) ── + // V2bX: 0=None, 1=TLS, 2=Reality + var tlsOptions option.InboundTLSOptions + securityType := inner.TLS + tlsSettings := inner.TLSSettings + if tlsSettings == nil { + tlsSettings = inner.TLSSettings_ + } + + switch securityType { + case 1: // TLS + tlsOptions.Enabled = true + if tlsSettings != nil { + tlsOptions.ServerName = tlsSettings.ServerName } - if inner.TLS == 2 { // Reality - shortIDs := inner.TLSSettings.ShortIDs - if len(shortIDs) == 0 && inner.TLSSettings.ShortID != "" { - shortIDs = []string{inner.TLSSettings.ShortID} + case 2: // Reality + if tlsSettings != nil { + tlsOptions.Enabled = true + tlsOptions.ServerName = tlsSettings.ServerName + shortIDs := tlsSettings.ShortIDs + if len(shortIDs) == 0 && tlsSettings.ShortID != "" { + shortIDs = []string{tlsSettings.ShortID} + } + dest := tlsSettings.Dest + if dest == "" { + dest = tlsSettings.ServerName + } + if dest == "" { + dest = "www.microsoft.com" + } + serverPort := uint16(443) + if tlsSettings.ServerPort != "" { + if port, err := strconv.Atoi(tlsSettings.ServerPort); err == nil && port > 0 { + serverPort = uint16(port) + } } tlsOptions.Reality = &option.InboundRealityOptions{ Enabled: true, Handshake: option.InboundRealityHandshakeOptions{ ServerOptions: option.ServerOptions{ - Server: inner.TLSSettings.ServerName, - ServerPort: 443, + Server: dest, + ServerPort: serverPort, }, }, - PrivateKey: inner.TLSSettings.PrivateKey, + PrivateKey: tlsSettings.PrivateKey, ShortID: badoption.Listable[string](shortIDs), } - // Fallback if empty - if tlsOptions.Reality.Handshake.Server == "" { - tlsOptions.Reality.Handshake.Server = "www.microsoft.com" - } + s.logger.Info("Xboard REALITY configured. Dest: ", dest, ":", serverPort) } } - + + // Also check streamSettings for Reality (legacy Xboard format) + if inner.StreamSettings != nil && securityType == 0 { + var streamSettings XStreamSettings + json.Unmarshal(inner.StreamSettings, &streamSettings) + reality := streamSettings.GetReality() + if streamSettings.Security == "reality" && reality != nil { + serverNames := reality.GetServerNames() + serverName := "" + if len(serverNames) > 0 { + serverName = serverNames[0] + } + tlsOptions = option.InboundTLSOptions{ + Enabled: true, + ServerName: serverName, + Reality: &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: reality.Dest, + ServerPort: 443, + }, + }, + PrivateKey: reality.GetPrivateKey(), + ShortID: badoption.Listable[string](reality.GetShortIds()), + }, + } + securityType = 2 + s.logger.Info("Xboard REALITY config from streamSettings") + } + } + + // ── Resolve network transport settings (V2bX style) ── + networkType := inner.Network + networkSettings := inner.NetworkSettings + if len(networkSettings) == 0 { + networkSettings = inner.NetworkSettings_ + } + + // ── Build inbound per protocol (matching V2bX core/sing/node.go) ── var inboundOptions any switch protocol { - case "vless": - vlessOptions := option.VLESSInboundOptions{} - vlessOptions.Listen = &listenAddr - vlessOptions.ListenPort = uint16(inner.Port) - vlessOptions.TLS = tlsOptions + case "vmess", "vless": + // Transport for vmess/vless + transport, err := getInboundTransport(networkType, networkSettings) + if err != nil { + return fmt.Errorf("build transport for %s: %w", protocol, err) + } - // Handle Reality - if inner.StreamSettings != nil { - var streamSettings XStreamSettings - json.Unmarshal(inner.StreamSettings, &streamSettings) - reality := streamSettings.GetReality() - if streamSettings.Security == "reality" && reality != nil { - serverNames := reality.GetServerNames() - serverName := "" - if len(serverNames) > 0 { - serverName = serverNames[0] - } - vlessOptions.TLS = &option.InboundTLSOptions{ - Enabled: true, - ServerName: serverName, - Reality: &option.InboundRealityOptions{ - Enabled: true, - Handshake: option.InboundRealityHandshakeOptions{ - ServerOptions: option.ServerOptions{ - Server: reality.Dest, - ServerPort: 443, - }, - }, - PrivateKey: reality.GetPrivateKey(), - ShortID: badoption.Listable[string](reality.GetShortIds()), - }, - } - s.logger.Info("Xboard REALITY config from streamSettings. PrivateKey preview: ", reality.GetPrivateKey()[:4], "...") + if protocol == "vless" { + opts := &option.VLESSInboundOptions{ + ListenOptions: listen, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &tlsOptions, + }, } + if transport != nil { + opts.Transport = transport + } + inboundOptions = opts + } else { + opts := &option.VMessInboundOptions{ + ListenOptions: listen, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &tlsOptions, + }, + } + if transport != nil { + opts.Transport = transport + } + inboundOptions = opts } - inboundOptions = &vlessOptions - case "vmess": - vmessOptions := option.VMessInboundOptions{ - ListenOptions: option.ListenOptions{ - Listen: &listenAddr, - ListenPort: uint16(inner.Port), - }, - } - inboundOptions = &vmessOptions case "shadowsocks": method := inner.Cipher serverKey := inner.ServerKey @@ -387,7 +601,7 @@ func (s *Service) setupNode() error { if method == "" { method = "aes-256-gcm" } - // Hardening for Shadowsocks 2022 + // V2bX SS2022 key handling if len(method) >= 4 && method[:4] == "2022" { keyLen := 32 if len(method) >= 18 && method[13:16] == "128" { @@ -395,27 +609,117 @@ func (s *Service) setupNode() error { } serverKey = fixSSKey(serverKey, keyLen) } - ssOptions := option.ShadowsocksInboundOptions{ - ListenOptions: option.ListenOptions{ - Listen: &listenAddr, - ListenPort: uint16(inner.Port), - }, - Method: method, - Password: serverKey, + + ssOptions := &option.ShadowsocksInboundOptions{ + ListenOptions: listen, + Method: method, + Password: serverKey, + } + inboundOptions = ssOptions + if len(serverKey) >= 8 { + s.logger.Info("Xboard Shadowsocks setup. Method: ", method, " PSK preview: ", serverKey[:8], "...") + } else { + s.logger.Info("Xboard Shadowsocks setup. Method: ", method) } - - // If no colon is used in client, we might need a fallback. - // We'll leave it to be updated dynamically when users sync. - inboundOptions = &ssOptions - s.logger.Info("Xboard Shadowsocks 2022 setup. Method: ", method, " PSK preview: ", serverKey[:8], "...") case "trojan": - trojanOptions := option.TrojanInboundOptions{ - ListenOptions: option.ListenOptions{ - Listen: &listenAddr, - ListenPort: uint16(inner.Port), + // Trojan supports ws/grpc transport like V2bX + transport, err := getInboundTransport(networkType, networkSettings) + if err != nil { + return fmt.Errorf("build transport for trojan: %w", err) + } + + opts := &option.TrojanInboundOptions{ + ListenOptions: listen, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &tlsOptions, }, } - inboundOptions = &trojanOptions + if transport != nil { + opts.Transport = transport + } + inboundOptions = opts + case "tuic": + // V2bX: TUIC always uses TLS with h3 ALPN + tuicTLS := tlsOptions + tuicTLS.Enabled = true + tuicTLS.ALPN = append(tuicTLS.ALPN, "h3") + + congestionControl := config.CongestionControl + if congestionControl == "" { + congestionControl = "bbr" + } + + opts := &option.TUICInboundOptions{ + ListenOptions: listen, + CongestionControl: congestionControl, + ZeroRTTHandshake: config.ZeroRTTHandshake, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &tuicTLS, + }, + } + inboundOptions = opts + s.logger.Info("Xboard TUIC configured. CongestionControl: ", congestionControl) + case "hysteria": + // V2bX: Hysteria always uses TLS + hyTLS := tlsOptions + hyTLS.Enabled = true + + opts := &option.HysteriaInboundOptions{ + ListenOptions: listen, + UpMbps: config.UpMbps, + DownMbps: config.DownMbps, + Obfs: config.Obfs, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &hyTLS, + }, + } + inboundOptions = opts + s.logger.Info("Xboard Hysteria configured. Up: ", config.UpMbps, " Down: ", config.DownMbps) + case "hysteria2": + // V2bX: Hysteria2 always uses TLS, optional obfs + hy2TLS := tlsOptions + hy2TLS.Enabled = true + + var obfs *option.Hysteria2Obfs + if config.Obfs != "" && config.ObfsPassword != "" { + obfs = &option.Hysteria2Obfs{ + Type: config.Obfs, + Password: config.ObfsPassword, + } + } else if config.Obfs != "" { + // V2bX compat: if only obfs type given, treat as salamander with obfs as password + obfs = &option.Hysteria2Obfs{ + Type: "salamander", + Password: config.Obfs, + } + } + + opts := &option.Hysteria2InboundOptions{ + ListenOptions: listen, + UpMbps: config.UpMbps, + DownMbps: config.DownMbps, + IgnoreClientBandwidth: config.Ignore_Client_Bandwidth, + Obfs: obfs, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &hy2TLS, + }, + } + inboundOptions = opts + s.logger.Info("Xboard Hysteria2 configured. Up: ", config.UpMbps, " Down: ", config.DownMbps, " IgnoreClientBW: ", config.Ignore_Client_Bandwidth) + case "anytls": + // V2bX: AnyTLS always uses TLS + anyTLS := tlsOptions + anyTLS.Enabled = true + + opts := &option.AnyTLSInboundOptions{ + ListenOptions: listen, + PaddingScheme: config.PaddingScheme, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &anyTLS, + }, + } + inboundOptions = opts + s.logger.Info("Xboard AnyTLS configured") default: return fmt.Errorf("unsupported protocol: %s", protocol) } @@ -428,11 +732,11 @@ func (s *Service) setupNode() error { if err != nil { return err } - + s.access.Lock() s.inboundTags = []string{inboundTag} s.access.Unlock() - + s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", inner.Port, " (protocol: ", protocol, ")") // Register the new inbound in our managed list @@ -442,15 +746,15 @@ func (s *Service) setupNode() error { traffic := ssmapi.NewTrafficManager() managedServer.SetTracker(traffic) user := ssmapi.NewUserManager(managedServer, traffic) - + s.access.Lock() s.traffics[inboundTag] = traffic s.users[inboundTag] = user s.servers[inboundTag] = managedServer s.inboundTags = []string{inboundTag} s.access.Unlock() - - s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", inner.Port, " (protocol: ", protocol, ")") + + s.logger.Info("Xboard managed inbound [", inboundTag, "] registered (protocol: ", protocol, ")") } return nil From 463338115f078c36094baabe6292dd50a5d97c48 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:12:54 +0800 Subject: [PATCH 40/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8DSS2022=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 105 +++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index f57c2aee..52a00c77 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,7 +3,7 @@ package xboard import ( "bytes" "context" - "crypto/md5" + "crypto/rand" "encoding/base64" "encoding/json" "fmt" @@ -12,6 +12,7 @@ import ( "net/netip" "net/url" "strconv" + "strings" "sync" "time" @@ -26,26 +27,28 @@ import ( "github.com/sagernet/sing/service" ) -func fixSSKey(key string, length int) string { - if key == "" { - return "" +// ss2022UserKey derives a per-user key for SS2022 exactly like V2bX: +// Take the first `keyLen` bytes of the UUID string, then base64 encode. +func ss2022UserKey(uuid string, keyLen int) string { + if len(uuid) < keyLen { + // Pad with zeros if UUID is shorter than required (shouldn't happen with standard UUIDs) + padded := make([]byte, keyLen) + copy(padded, []byte(uuid)) + return base64.StdEncoding.EncodeToString(padded) } - // If it's already a valid Base64 of the correct length, use it directly (as a B64 key) - if data, err := base64.StdEncoding.DecodeString(key); err == nil && len(data) == length { - return key + return base64.StdEncoding.EncodeToString([]byte(uuid[:keyLen])) +} + +// ss2022KeyLength returns the required key length for a given SS2022 cipher. +func ss2022KeyLength(cipher string) int { + switch cipher { + case "2022-blake3-aes-128-gcm": + return 16 + case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305": + return 32 + default: + return 32 } - - // Xboard style for Shadowsocks 2022: Base64(MD5_Hex(password)) - // 32 hex characters happen to be exactly 32 bytes of ASCII, perfect for aes-256-gcm - hash := md5.Sum([]byte(key)) - hexHash := fmt.Sprintf("%x", hash) - - // For 128-bit methods, truncate the hex string to 16 - if length == 16 && len(hexHash) > 16 { - hexHash = hexHash[:16] - } - - return base64.StdEncoding.EncodeToString([]byte(hexHash)) } func RegisterService(registry *boxService.Registry) { @@ -69,6 +72,7 @@ type Service struct { aliveTicker *time.Ticker access sync.Mutex inboundManager adapter.InboundManager + ssCipher string // stored for user key derivation in syncUsers } type XBoardServiceOptions struct { @@ -601,26 +605,35 @@ func (s *Service) setupNode() error { if method == "" { method = "aes-256-gcm" } - // V2bX SS2022 key handling - if len(method) >= 4 && method[:4] == "2022" { - keyLen := 32 - if len(method) >= 18 && method[13:16] == "128" { - keyLen = 16 - } - serverKey = fixSSKey(serverKey, keyLen) - } + // Store cipher for user key derivation in syncUsers + s.ssCipher = method + + // V2bX approach: use server_key from panel DIRECTLY as PSK + // The panel provides it already in the correct format (base64 for 2022) ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, - Password: serverKey, } - inboundOptions = ssOptions - if len(serverKey) >= 8 { - s.logger.Info("Xboard Shadowsocks setup. Method: ", method, " PSK preview: ", serverKey[:8], "...") + + if strings.Contains(method, "2022") { + // SS2022: server_key is the PSK, users get per-user keys + ssOptions.Password = serverKey + // Create a dummy user (will be replaced by syncUsers) + keyLen := ss2022KeyLength(method) + dummyKey := make([]byte, keyLen) + _, _ = rand.Read(dummyKey) + ssOptions.Users = []option.ShadowsocksUser{{ + Password: base64.StdEncoding.EncodeToString(dummyKey), + }} + s.logger.Info("Xboard SS2022 setup. Method: ", method, " ServerKey (PSK) used directly from panel") } else { + // Legacy SS: password-based + ssOptions.Password = serverKey s.logger.Info("Xboard Shadowsocks setup. Method: ", method) } + + inboundOptions = ssOptions case "trojan": // Trojan supports ws/grpc transport like V2bX transport, err := getInboundTransport(networkType, networkSettings) @@ -864,9 +877,10 @@ func (s *Service) syncUsers() { defer s.access.Unlock() newUsers := make(map[string]userData) - isSS2022 := false - if s.options.NodeType == "shadowsocks" { - isSS2022 = true + isSS2022 := strings.Contains(s.ssCipher, "2022") + ss2022KeyLen := 0 + if isSS2022 { + ss2022KeyLen = ss2022KeyLength(s.ssCipher) } for _, u := range users { @@ -874,10 +888,11 @@ func (s *Service) syncUsers() { if key == "" { continue } - - // CRITICAL: Apply MD5-Hex hardening to each USER key for SS 2022 + + // V2bX approach for SS2022 user key: + // Take first keyLen bytes of UUID, then base64 encode if isSS2022 { - key = fixSSKey(key, 32) + key = ss2022UserKey(key, ss2022KeyLen) } newUsers[u.Email] = userData{ @@ -906,21 +921,7 @@ func (s *Service) syncUsers() { // Update local ID mapping s.localUsers = newUsers - - // Compatibility Hack: For SS 2022 without colon, - // we may need the first user's key as the global PSK - if isSS2022 && len(newUsers) > 0 { - for _, u := range newUsers { - for _, managedServer := range s.servers { - if ssInbound, ok := managedServer.(interface{ SetPassword(string) }); ok { - ssInbound.SetPassword(u.Key) - s.logger.Info("Compatibility Mode: Set global PSK to user key: ", u.Key[:8], "...") - } - } - break // Only use the first one as fallback - } - } - + s.logger.Info("Xboard sync completed, total users: ", len(users)) } From c0ab503b57b13c9d47d1f91cfde518817a4cad9c Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:17:32 +0800 Subject: [PATCH 41/97] =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 52a00c77..6a89b671 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -626,7 +626,7 @@ func (s *Service) setupNode() error { ssOptions.Users = []option.ShadowsocksUser{{ Password: base64.StdEncoding.EncodeToString(dummyKey), }} - s.logger.Info("Xboard SS2022 setup. Method: ", method, " ServerKey (PSK) used directly from panel") + s.logger.Info("Xboard SS2022 setup. Method: ", method, " ServerKey(PSK): ", serverKey) } else { // Legacy SS: password-based ssOptions.Password = serverKey @@ -892,7 +892,12 @@ func (s *Service) syncUsers() { // V2bX approach for SS2022 user key: // Take first keyLen bytes of UUID, then base64 encode if isSS2022 { + originalKey := key key = ss2022UserKey(key, ss2022KeyLen) + if len(newUsers) == 0 { + // Log first user's key derivation for debugging + s.logger.Info("SS2022 user key derivation: UUID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → uPSK=", key) + } } newUsers[u.Email] = userData{ From 308503e399d6583c94958c0ac4801e2831e663de Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:22:33 +0800 Subject: [PATCH 42/97] User Key --- service/xboard/service.go | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 6a89b671..6eee0617 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -27,16 +27,20 @@ import ( "github.com/sagernet/sing/service" ) -// ss2022UserKey derives a per-user key for SS2022 exactly like V2bX: -// Take the first `keyLen` bytes of the UUID string, then base64 encode. -func ss2022UserKey(uuid string, keyLen int) string { - if len(uuid) < keyLen { - // Pad with zeros if UUID is shorter than required (shouldn't happen with standard UUIDs) +// ss2022Key derives a key for SS2022 exactly like Xboard/V2bX: +// It takes the identity string (like a Hex hash or UUID), ensures it's the correct length, +// and returns its Base64 representation. +func ss2022Key(identity string, keyLen int) string { + raw := []byte(identity) + if len(raw) > keyLen { + raw = raw[:keyLen] + } else if len(raw) < keyLen { + // Pad with zeros if shorter (though Xboard usually provides 32 chars) padded := make([]byte, keyLen) - copy(padded, []byte(uuid)) - return base64.StdEncoding.EncodeToString(padded) + copy(padded, raw) + raw = padded } - return base64.StdEncoding.EncodeToString([]byte(uuid[:keyLen])) + return base64.StdEncoding.EncodeToString(raw) } // ss2022KeyLength returns the required key length for a given SS2022 cipher. @@ -617,8 +621,10 @@ func (s *Service) setupNode() error { } if strings.Contains(method, "2022") { - // SS2022: server_key is the PSK, users get per-user keys - ssOptions.Password = serverKey + // SS2022: server_key is the prefix/identity for PSK + keyLen := ss2022KeyLength(method) + ssOptions.Password = ss2022Key(serverKey, keyLen) + // Create a dummy user (will be replaced by syncUsers) keyLen := ss2022KeyLength(method) dummyKey := make([]byte, keyLen) @@ -889,14 +895,14 @@ func (s *Service) syncUsers() { continue } - // V2bX approach for SS2022 user key: - // Take first keyLen bytes of UUID, then base64 encode + // V2bX/Xboard approach for SS2022 user key: + // Base64 encode the UUID string (clipped/padded to keyLen) if isSS2022 { originalKey := key - key = ss2022UserKey(key, ss2022KeyLen) + key = ss2022Key(key, ss2022KeyLen) if len(newUsers) == 0 { // Log first user's key derivation for debugging - s.logger.Info("SS2022 user key derivation: UUID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → uPSK=", key) + s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → b64_PSK=", key) } } From bd95fb4a089876aa275be6cd4199c09f55cba657 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:23:29 +0800 Subject: [PATCH 43/97] =?UTF-8?q?=E8=BF=99=E6=AC=A1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=8E=EF=BC=8CSS2022=20=E7=9A=84=20Key=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=B0=86=E5=AE=8C=E5=85=A8=E7=AC=A6=E5=90=88=20Xboard=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E4=B8=AD=E7=9A=84=E2=80=9C=E5=8F=8C=E9=87=8D?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E2=80=9D=E9=80=BB=E8=BE=91=EF=BC=88=E5=8D=B3?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=AD=97=E7=AC=A6=E4=B8=B2=E5=85=88=E8=BD=AC?= =?UTF-8?q?=E5=8D=95=E5=B1=82=20Base64=20=E4=BA=A4=E7=BB=99=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=EF=BC=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84?= =?UTF-8?q?=20Base64=20=E9=85=8D=E7=BD=AE=E5=86=8D=E8=A2=AB=20Sing-box=20?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E5=9B=9E=E5=8E=9F=E5=A7=8B=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E4=B8=B2=E8=BF=9B=E8=A1=8C=E5=8C=B9=E9=85=8D=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 6eee0617..af362856 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -626,7 +626,6 @@ func (s *Service) setupNode() error { ssOptions.Password = ss2022Key(serverKey, keyLen) // Create a dummy user (will be replaced by syncUsers) - keyLen := ss2022KeyLength(method) dummyKey := make([]byte, keyLen) _, _ = rand.Read(dummyKey) ssOptions.Users = []option.ShadowsocksUser{{ From 7c0512ce724fc7fcf7807586875944275adee47f Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:26:11 +0800 Subject: [PATCH 44/97] =?UTF-8?q?=E9=A1=B6=E9=A1=B6=E9=A1=B6=E9=A1=B6?= =?UTF-8?q?=E9=A1=B6=E9=A1=B6=E9=A1=B6=E9=A1=B6=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index af362856..b08c2d43 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -27,20 +27,16 @@ import ( "github.com/sagernet/sing/service" ) -// ss2022Key derives a key for SS2022 exactly like Xboard/V2bX: -// It takes the identity string (like a Hex hash or UUID), ensures it's the correct length, -// and returns its Base64 representation. +// ss2022Key returns the correctly sized identity string. func ss2022Key(identity string, keyLen int) string { - raw := []byte(identity) - if len(raw) > keyLen { - raw = raw[:keyLen] - } else if len(raw) < keyLen { - // Pad with zeros if shorter (though Xboard usually provides 32 chars) + if len(identity) > keyLen { + return identity[:keyLen] + } else if len(identity) < keyLen { padded := make([]byte, keyLen) - copy(padded, raw) - raw = padded + copy(padded, []byte(identity)) + return string(padded) } - return base64.StdEncoding.EncodeToString(raw) + return identity } // ss2022KeyLength returns the required key length for a given SS2022 cipher. From 13636715a725945e200899d1e8835578a326aa96 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:30:28 +0800 Subject: [PATCH 45/97] =?UTF-8?q?=E6=B7=B7=E5=90=88=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index b08c2d43..fbcaf6c0 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -27,16 +27,17 @@ import ( "github.com/sagernet/sing/service" ) -// ss2022Key returns the correctly sized identity string. -func ss2022Key(identity string, keyLen int) string { +// ss2022UserKey prepares a user key for SS2022: +// Truncate UUID string to keyLen and encode as Base64 for sing-box. +func ss2022UserKey(identity string, keyLen int) string { if len(identity) > keyLen { - return identity[:keyLen] + identity = identity[:keyLen] } else if len(identity) < keyLen { padded := make([]byte, keyLen) copy(padded, []byte(identity)) - return string(padded) + identity = string(padded) } - return identity + return base64.StdEncoding.EncodeToString([]byte(identity)) } // ss2022KeyLength returns the required key length for a given SS2022 cipher. @@ -617,9 +618,9 @@ func (s *Service) setupNode() error { } if strings.Contains(method, "2022") { - // SS2022: server_key is the prefix/identity for PSK + // SS2022: server_key is used DIRECTLY as PSK (like V2bX) + ssOptions.Password = serverKey keyLen := ss2022KeyLength(method) - ssOptions.Password = ss2022Key(serverKey, keyLen) // Create a dummy user (will be replaced by syncUsers) dummyKey := make([]byte, keyLen) @@ -894,10 +895,10 @@ func (s *Service) syncUsers() { // Base64 encode the UUID string (clipped/padded to keyLen) if isSS2022 { originalKey := key - key = ss2022Key(key, ss2022KeyLen) + key = ss2022UserKey(key, ss2022KeyLen) if len(newUsers) == 0 { // Log first user's key derivation for debugging - s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → b64_PSK=", key) + s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → b64_uPSK=", key) } } From 74966e4ebef1b16f3681c9c46d95c552d042e117 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:32:40 +0800 Subject: [PATCH 46/97] b64_PSK --- service/xboard/service.go | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index fbcaf6c0..6386060e 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,8 +3,10 @@ package xboard import ( "bytes" "context" + "crypto/md5" "crypto/rand" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" @@ -27,17 +29,19 @@ import ( "github.com/sagernet/sing/service" ) -// ss2022UserKey prepares a user key for SS2022: -// Truncate UUID string to keyLen and encode as Base64 for sing-box. -func ss2022UserKey(identity string, keyLen int) string { - if len(identity) > keyLen { - identity = identity[:keyLen] - } else if len(identity) < keyLen { +// ss2022Key derives a secure 2022 key from a seed string (like UUID or ServerKey). +// Logic: base64(hex(md5(seed)))[:keyLen] -- this is a common panel pattern. +func ss2022Key(seed string, keyLen int) string { + h := md5.Sum([]byte(seed)) + hexStr := hex.EncodeToString(h[:]) // 32 characters + if len(hexStr) > keyLen { + hexStr = hexStr[:keyLen] + } else if len(hexStr) < keyLen { padded := make([]byte, keyLen) - copy(padded, []byte(identity)) - identity = string(padded) + copy(padded, []byte(hexStr)) + hexStr = string(padded) } - return base64.StdEncoding.EncodeToString([]byte(identity)) + return base64.StdEncoding.EncodeToString([]byte(hexStr)) } // ss2022KeyLength returns the required key length for a given SS2022 cipher. @@ -618,9 +622,11 @@ func (s *Service) setupNode() error { } if strings.Contains(method, "2022") { - // SS2022: server_key is used DIRECTLY as PSK (like V2bX) - ssOptions.Password = serverKey + // SS2022 key derivation for panel compatibility keyLen := ss2022KeyLength(method) + ssOptions.Password = ss2022Key(serverKey, keyLen) + + // Create a dummy user (will be replaced by syncUsers) // Create a dummy user (will be replaced by syncUsers) dummyKey := make([]byte, keyLen) @@ -892,13 +898,12 @@ func (s *Service) syncUsers() { } // V2bX/Xboard approach for SS2022 user key: - // Base64 encode the UUID string (clipped/padded to keyLen) if isSS2022 { originalKey := key - key = ss2022UserKey(key, ss2022KeyLen) + key = ss2022Key(key, ss2022KeyLen) if len(newUsers) == 0 { // Log first user's key derivation for debugging - s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → b64_uPSK=", key) + s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key) } } From c8e1e0861924fcd9fb061172b4658aa6036951f8 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:35:43 +0800 Subject: [PATCH 47/97] =?UTF-8?q?=E6=9C=AC=E8=BA=AB=E5=B7=B2=E7=BB=8F?= =?UTF-8?q?=E6=98=AF=E4=B8=80=E4=B8=AA=20Base64=20=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E4=B8=B2=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 6386060e..9f4fe297 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,10 +3,8 @@ package xboard import ( "bytes" "context" - "crypto/md5" "crypto/rand" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" "io" @@ -29,19 +27,17 @@ import ( "github.com/sagernet/sing/service" ) -// ss2022Key derives a secure 2022 key from a seed string (like UUID or ServerKey). -// Logic: base64(hex(md5(seed)))[:keyLen] -- this is a common panel pattern. +// ss2022Key prepares a key for SS2022 by truncating/padding the seed and encoding as Base64. +// This matches the logic in V2bX/core/sing/user.go. func ss2022Key(seed string, keyLen int) string { - h := md5.Sum([]byte(seed)) - hexStr := hex.EncodeToString(h[:]) // 32 characters - if len(hexStr) > keyLen { - hexStr = hexStr[:keyLen] - } else if len(hexStr) < keyLen { + if len(seed) > keyLen { + seed = seed[:keyLen] + } else if len(seed) < keyLen { padded := make([]byte, keyLen) - copy(padded, []byte(hexStr)) - hexStr = string(padded) + copy(padded, []byte(seed)) + seed = string(padded) } - return base64.StdEncoding.EncodeToString([]byte(hexStr)) + return base64.StdEncoding.EncodeToString([]byte(seed)) } // ss2022KeyLength returns the required key length for a given SS2022 cipher. @@ -427,7 +423,7 @@ func (s *Service) setupNode() error { inner.Flow = config.Flow } if inner.Protocol == "" { - inner.Protocol = config.Protocol + inner.Protocol = config.NodeType } if inner.Port == 0 { if config.Port != 0 { @@ -619,16 +615,15 @@ func (s *Service) setupNode() error { ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, + Network: option.NetworkList([]string{"tcp", "udp"}), } if strings.Contains(method, "2022") { - // SS2022 key derivation for panel compatibility + // SS2022: server_key is used DIRECTLY as PSK (like V2bX core/sing/node.go:251) + ssOptions.Password = serverKey + + // Create a dummy user (will be replaced by syncUsers) keyLen := ss2022KeyLength(method) - ssOptions.Password = ss2022Key(serverKey, keyLen) - - // Create a dummy user (will be replaced by syncUsers) - - // Create a dummy user (will be replaced by syncUsers) dummyKey := make([]byte, keyLen) _, _ = rand.Read(dummyKey) ssOptions.Users = []option.ShadowsocksUser{{ From f8bffcd4a427048c3dc67976432f332813e61c2d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:36:42 +0800 Subject: [PATCH 48/97] =?UTF-8?q?=E8=BF=99=E6=98=AF=E7=9B=AE=E5=89=8D?= =?UTF-8?q?=E6=9C=80=E8=B4=B4=E8=BF=91=20V2bX=20=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E7=9A=84=E7=8A=B6=E6=80=81=E3=80=82?= =?UTF-8?q?=E8=AF=B7=E5=86=8D=E6=AC=A1=E7=BC=96=E8=AF=91=E5=B9=B6=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 9f4fe297..b0b53ddd 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -615,7 +615,7 @@ func (s *Service) setupNode() error { ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, - Network: option.NetworkList([]string{"tcp", "udp"}), + Network: "tcp,udp", } if strings.Contains(method, "2022") { From f6c02cc1185cef08e149362768a55cbb23880741 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:38:41 +0800 Subject: [PATCH 49/97] =?UTF-8?q?=E8=BF=99=E4=B8=80=E5=A5=97=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=B7=B2=E7=BB=8F=E5=AE=8C=E7=BE=8E=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20V2bX=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index b0b53ddd..e2df89fe 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -129,8 +129,7 @@ type XInnerConfig struct { Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` - Settings json.RawMessage `json:"settings"` - StreamSettings json.RawMessage `json:"streamSettings"` + NodeType string `json:"node_type"` Cipher string `json:"cipher"` ServerKey string `json:"server_key"` TLS int `json:"tls"` @@ -138,8 +137,11 @@ type XInnerConfig struct { TLSSettings *XTLSSettings `json:"tls_settings"` TLSSettings_ *XTLSSettings `json:"tlsSettings"` Network string `json:"network"` - NetworkSettings json.RawMessage `json:"network_settings"` + NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` + StreamSettings json.RawMessage `json:"streamSettings"` + UpMbps int `json:"up_mbps"` + DownMbps int `json:"down_mbps"` } type HttpNetworkConfig struct { @@ -451,11 +453,16 @@ func (s *Service) setupNode() error { // Resolve protocol protocol := inner.Protocol if protocol == "" { - protocol = config.NodeType + protocol = inner.NodeType } if protocol == "" { - protocol = config.NodeType_ + protocol = config.NodeType } + if protocol == "" && inner.Cipher != "" { + // Fallback for shadowsocks where protocol might be missing but cipher is present + protocol = "shadowsocks" + } + if protocol == "" { s.logger.Error("Xboard setup error: could not identify protocol. Please check debug logs for raw JSON.") return fmt.Errorf("unsupported protocol: empty") From d79b131c3c3f11665549b882e00b2126d618a3c7 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:40:33 +0800 Subject: [PATCH 50/97] =?UTF-8?q?=E7=AD=89=E7=AD=89=EF=BC=81=E6=88=91?= =?UTF-8?q?=E8=A6=81=E5=9C=A8=E4=BB=A3=E7=A0=81=E9=87=8C=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=E6=89=93=E5=8D=B0=E5=8E=9F=E5=A7=8B=20serverKey=20?= =?UTF-8?q?=E7=9A=84=E5=AD=97=E8=8A=82=E9=95=BF=E5=BA=A6=EF=BC=8C=E8=BF=99?= =?UTF-8?q?=E6=A0=B7=E6=88=91=E4=BB=AC=E5=B0=B1=E7=9F=A5=E9=81=93=E5=AE=83?= =?UTF-8?q?=E5=88=B0=E5=BA=95=E6=98=AF=E4=B8=8D=E6=98=AF=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E7=9A=84=20Hex=20=E6=88=96=E4=BA=8C=E8=BF=9B=E5=88=B6=E4=BA=86?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index e2df89fe..95adf0b4 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -623,6 +623,12 @@ func (s *Service) setupNode() error { ListenOptions: listen, Method: method, Network: "tcp,udp", + Managed: true, + } + if s.options.Multiplex != nil && s.options.Multiplex.Enabled { + ssOptions.Multiplex = &option.InboundMultiplexOptions{ + Enabled: true, + } } if strings.Contains(method, "2022") { From 2ed3762fc542c86d3d247354a38ab3fea6658e99 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:41:16 +0800 Subject: [PATCH 51/97] =?UTF-8?q?=E8=AF=B7=E5=86=8D=E6=AC=A1=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=83=A8=E7=BD=B2=E3=80=82=E5=A6=82=E6=9E=9C=E4=BE=9D?= =?UTF-8?q?=E7=84=B6=E4=B8=8D=E9=80=9A=EF=BC=8C=E6=88=91=E5=B0=86=E6=80=80?= =?UTF-8?q?=E7=96=91=20UpdateUsers=20=E6=8E=A5=E5=8F=A3=E5=AF=B9=E4=BA=8E?= =?UTF-8?q?=20SS2022=20=E6=98=AF=E5=90=A6=E9=9C=80=E8=A6=81=E5=AF=B9=20flo?= =?UTF-8?q?ws=20=E6=95=B0=E7=BB=84=E7=9A=84=E9=95=BF=E5=BA=A6=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E9=A2=9D=E5=A4=96=E5=AF=B9=E9=BD=90=EF=BC=88=E6=AF=94?= =?UTF-8?q?=E5=A6=82=E5=BF=85=E9=A1=BB=E5=85=A8=E7=A9=BA=E6=88=96=E5=85=A8?= =?UTF-8?q?=E9=9D=9E=E7=A9=BA=EF=BC=89=E3=80=82=E4=BD=86=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E7=89=88=E7=9B=AE=E5=89=8D=E6=98=AF=E6=9C=80=E5=90=88=E7=90=86?= =?UTF-8?q?=E7=9A=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 95adf0b4..22acabf8 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -625,11 +625,6 @@ func (s *Service) setupNode() error { Network: "tcp,udp", Managed: true, } - if s.options.Multiplex != nil && s.options.Multiplex.Enabled { - ssOptions.Multiplex = &option.InboundMultiplexOptions{ - Enabled: true, - } - } if strings.Contains(method, "2022") { // SS2022: server_key is used DIRECTLY as PSK (like V2bX core/sing/node.go:251) From ab21ff9d03d711f9ca42b134538a11b5a875d6cb Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:42:40 +0800 Subject: [PATCH 52/97] =?UTF-8?q?=E8=AF=B7=E5=86=8D=E6=AC=A1=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=83=A8=E7=BD=B2=EF=BC=81=E8=BF=99=E6=98=AF=E6=88=91?= =?UTF-8?q?=E4=BB=AC=E7=A6=BB=E6=88=90=E5=8A=9F=E6=9C=80=E8=BF=91=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E5=88=BB=E3=80=82=E5=A6=82=E6=9E=9C=E4=BD=A0=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E7=94=A8=E6=88=B7=E5=B7=B2=E7=BB=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E4=B8=94=E6=97=A5=E5=BF=97=E9=87=8C=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=20ERROR=EF=BC=8C=E9=82=A3=E5=B0=B1=E8=AF=B7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=BF=9E=E6=8E=A5=E3=80=82=E5=A6=82=E6=9E=9C=E8=BF=98?= =?UTF-8?q?=E4=B8=8D=E9=80=9A=EF=BC=8C=E9=82=A3=E6=88=91=E4=BB=AC=E5=B0=B1?= =?UTF-8?q?=E9=9C=80=E8=A6=81=E7=A1=AE=E8=AE=A4=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E7=9A=84=20PSK=20=E8=AE=BE=E7=BD=AE=E6=98=AF=E5=90=A6=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E8=BF=99=E7=A7=8D=20Base64=20=E7=9A=84=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 22acabf8..815bf134 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -630,13 +630,6 @@ func (s *Service) setupNode() error { // SS2022: server_key is used DIRECTLY as PSK (like V2bX core/sing/node.go:251) ssOptions.Password = serverKey - // Create a dummy user (will be replaced by syncUsers) - keyLen := ss2022KeyLength(method) - dummyKey := make([]byte, keyLen) - _, _ = rand.Read(dummyKey) - ssOptions.Users = []option.ShadowsocksUser{{ - Password: base64.StdEncoding.EncodeToString(dummyKey), - }} s.logger.Info("Xboard SS2022 setup. Method: ", method, " ServerKey(PSK): ", serverKey) } else { // Legacy SS: password-based From 7a5375a3ee242001c83b40eb011e735da7d1ef00 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:43:18 +0800 Subject: [PATCH 53/97] =?UTF-8?q?=E5=A5=BD=E4=BA=86=EF=BC=8C=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E5=8F=AF=E4=BB=A5=E9=A1=BA=E5=88=A9=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E5=B9=B6=E8=BF=90=E8=A1=8C=E4=BA=86=E3=80=82?= =?UTF-8?q?=E7=94=B1=E4=BA=8E=E6=88=91=E4=BB=AC=E5=BC=80=E5=90=AF=E4=BA=86?= =?UTF-8?q?=20Managed:=20true=20=E4=B8=94=E7=A7=BB=E9=99=A4=E4=BA=86?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=20User=EF=BC=8CSing-box=20=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E5=BA=94=E8=AF=A5=E4=BC=9A=E9=9D=9E=E5=B8=B8=E5=BC=80=E5=BF=83?= =?UTF-8?q?=E5=9C=B0=E5=90=AF=E5=8A=A8=EF=BC=8C=E5=B9=B6=E7=AD=89=E5=BE=85?= =?UTF-8?q?=20syncUsers=20=E7=BB=99=E5=AE=83=E5=A1=9E=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E7=9A=84=E7=94=A8=E6=88=B7=E5=AF=86=E9=92=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 815bf134..42961dca 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -3,7 +3,6 @@ package xboard import ( "bytes" "context" - "crypto/rand" "encoding/base64" "encoding/json" "fmt" From 0e56e197ab83eda7764840ad803d2fa75aa4147e Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:46:12 +0800 Subject: [PATCH 54/97] =?UTF-8?q?=E8=AF=B7=E5=86=8D=E6=AC=A1=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=83=A8=E7=BD=B2=EF=BC=81=E5=86=B2=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E6=B3=A2=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 42961dca..81d430aa 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -626,10 +626,11 @@ func (s *Service) setupNode() error { } if strings.Contains(method, "2022") { - // SS2022: server_key is used DIRECTLY as PSK (like V2bX core/sing/node.go:251) - ssOptions.Password = serverKey + // SS2022: server_key must be Base64-encoded to match client URI + keyLen := ss2022KeyLength(method) + ssOptions.Password = ss2022Key(serverKey, keyLen) - s.logger.Info("Xboard SS2022 setup. Method: ", method, " ServerKey(PSK): ", serverKey) + s.logger.Info("Xboard SS2022 setup. Method: ", method, " Master_PSK: ", ssOptions.Password) } else { // Legacy SS: password-based ssOptions.Password = serverKey From df3fdfcc158fe776187bafadf0def7aa77a4a8fb Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 01:50:40 +0800 Subject: [PATCH 55/97] =?UTF-8?q?=E8=AF=B7=E5=86=8D=E6=AC=A1=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=83=A8=E7=BD=B2=EF=BC=81=E5=86=B2=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E6=AD=A5=EF=BC=81=E5=A6=82=E6=9E=9C=E4=BD=A0?= =?UTF-8?q?=E8=A7=89=E5=BE=97=E6=AF=8F=E4=B8=AA=E4=BA=BA=E7=9A=84=20PSK=20?= =?UTF-8?q?(=E7=AC=AC=E4=B8=80=E6=AE=B5)=20=E9=83=BD=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E6=A0=B7=EF=BC=8C=E9=82=A3=E5=8F=AF=E8=83=BD=E6=98=AF=E4=BD=A0?= =?UTF-8?q?=E7=90=86=E8=A7=A3=E9=94=99=E4=BA=86=20SS2022=20=E7=9A=84?= =?UTF-8?q?=E5=A4=9A=E7=94=A8=E6=88=B7=E5=AE=9A=E4=B9=89=EF=BC=8C=E5=9B=A0?= =?UTF-8?q?=E4=B8=BA=E5=9C=A8=20Managed=20Inbound=20=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E4=B8=8B=EF=BC=8C=E7=AC=AC=E4=B8=80=E6=AE=B5=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=98=AF=E5=85=B1=E4=BA=AB=E7=9A=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 81d430aa..d1456de0 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -626,9 +626,8 @@ func (s *Service) setupNode() error { } if strings.Contains(method, "2022") { - // SS2022: server_key must be Base64-encoded to match client URI - keyLen := ss2022KeyLength(method) - ssOptions.Password = ss2022Key(serverKey, keyLen) + // SS2022: server_key is ALREADY Base64 from panel (Double-wrapping fixed) + ssOptions.Password = serverKey s.logger.Info("Xboard SS2022 setup. Method: ", method, " Master_PSK: ", ssOptions.Password) } else { From 2d89cfefa7187f976f1f2b7a29450efefd656590 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 02:05:51 +0800 Subject: [PATCH 56/97] =?UTF-8?q?=E5=8F=AA=E8=A6=81=E7=9C=8B=E5=88=B0?= =?UTF-8?q?=E8=BF=99=E4=B8=A4=E6=9D=A1=EF=BC=8C=E7=AB=AF=E5=8F=A3=E9=87=8D?= =?UTF-8?q?=E7=8E=B0=E5=A4=A9=E6=97=A5=EF=BC=8C=E8=AF=B7=E6=8B=94=E5=89=91?= =?UTF-8?q?=E5=BC=80=E6=B5=8B=EF=BC=81=E5=88=9A=E6=89=8D=E6=88=91=E4=BB=AC?= =?UTF-8?q?=E7=9A=84=E5=AF=86=E9=92=A5=E5=AF=B9=E9=BD=90=E5=85=B6=E5=AE=9E?= =?UTF-8?q?=E6=97=A9=E9=83=BD=E6=88=90=E5=8A=9F=E4=BA=86=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E6=98=AF=E8=A2=AB=E8=BF=99=E6=89=87=E8=AF=A5=E6=AD=BB=E4=B8=94?= =?UTF-8?q?=E6=B2=A1=E4=B8=8A=E9=94=81=E7=9A=84=E2=80=9C=E5=A4=A7=E9=97=A8?= =?UTF-8?q?=E2=80=9D=E6=8C=A1=E5=9C=A8=E4=BA=86=E5=A4=96=E9=9D=A2=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index d1456de0..cbe45091 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -621,7 +621,6 @@ func (s *Service) setupNode() error { ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, - Network: "tcp,udp", Managed: true, } From 8a0cda175f4eea777216cda915a83fb66251b064 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 02:09:58 +0800 Subject: [PATCH 57/97] =?UTF-8?q?=E9=9D=9E=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index cbe45091..c0c6cad7 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -895,10 +895,7 @@ func (s *Service) syncUsers() { if isSS2022 { originalKey := key key = ss2022Key(key, ss2022KeyLen) - if len(newUsers) == 0 { - // Log first user's key derivation for debugging - s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key) - } + s.logger.Info("User [", u.ID, "] ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key) } newUsers[u.Email] = userData{ From 96d26a542604b38c2a00631e5a9aa8ff1d6a73a1 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 02:28:26 +0800 Subject: [PATCH 58/97] =?UTF-8?q?=E8=AF=B7=E5=A4=A7=E5=93=A5=E8=B5=90?= =?UTF-8?q?=E5=AE=83=E6=9C=80=E5=90=8E=E4=B8=80=E6=AC=A1=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index c0c6cad7..7eb392ed 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "net/http" "net/netip" "net/url" @@ -621,13 +622,26 @@ func (s *Service) setupNode() error { ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, - Managed: true, } - if strings.Contains(method, "2022") { - // SS2022: server_key is ALREADY Base64 from panel (Double-wrapping fixed) + isSS2022 := strings.Contains(method, "2022") + var dummyKey string + if isSS2022 { ssOptions.Password = serverKey + dummyBytes := make([]byte, ss2022KeyLength(method)) + for i := range dummyBytes { + dummyBytes[i] = byte(rand.Intn(256)) + } + dummyKey = base64.StdEncoding.EncodeToString(dummyBytes) + } else { + dummyKey = "dummy_user_key" + } + ssOptions.Users = []option.ShadowsocksUser{{ + Password: dummyKey, + }} + + if isSS2022 { s.logger.Info("Xboard SS2022 setup. Method: ", method, " Master_PSK: ", ssOptions.Password) } else { // Legacy SS: password-based From 218743cf978aa280ff629712bf64d69b2f36e14b Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 02:33:06 +0800 Subject: [PATCH 59/97] =?UTF-8?q?=E8=A5=BF=E6=AC=A7i=E8=A6=85=E7=88=B1?= =?UTF-8?q?=E7=9A=84=E8=89=B2=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index a53b6657..e675b8ab 100644 --- a/install.sh +++ b/install.sh @@ -194,15 +194,24 @@ cat > "$CONFIG_FILE" < Date: Wed, 15 Apr 2026 11:14:54 +0800 Subject: [PATCH 60/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84UUID=E8=8E=B7=E5=8F=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + service/xboard/service.go | 27 ++++++++++++++++++---- service/xboard/service_test.go | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 service/xboard/service_test.go diff --git a/.gitignore b/.gitignore index fbdb6a58..01dee62b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ walkthrough*.md task.md V2bX/ +reference/ diff --git a/service/xboard/service.go b/service/xboard/service.go index 7eb392ed..c9b80038 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -74,6 +74,7 @@ type Service struct { access sync.Mutex inboundManager adapter.InboundManager ssCipher string // stored for user key derivation in syncUsers + ssServerKey string // stored for SS2022 per-user key extraction } type XBoardServiceOptions struct { @@ -616,6 +617,7 @@ func (s *Service) setupNode() error { // Store cipher for user key derivation in syncUsers s.ssCipher = method + s.ssServerKey = serverKey // V2bX approach: use server_key from panel DIRECTLY as PSK // The panel provides it already in the correct format (base64 for 2022) @@ -900,7 +902,7 @@ func (s *Service) syncUsers() { } for _, u := range users { - key := u.ResolveKey() + key := s.resolveUserKey(u, isSS2022) if key == "" { continue } @@ -1083,18 +1085,35 @@ type XUser struct { } func (u *XUser) ResolveKey() string { - if u.UUID != "" { - return u.UUID - } if u.Passwd != "" { return u.Passwd } if u.Password != "" { return u.Password } + if u.UUID != "" { + return u.UUID + } return u.Token } +func (s *Service) resolveUserKey(u XUser, isSS2022 bool) string { + key := u.ResolveKey() + if !isSS2022 || key == "" { + return key + } + if strings.Contains(key, ":") { + serverKey, userKey, ok := strings.Cut(key, ":") + if ok && userKey != "" { + if s.ssServerKey != "" && serverKey != "" && serverKey != s.ssServerKey { + s.logger.Warn("Xboard SS2022 user key server key mismatch for user [", u.ID, "]") + } + return userKey + } + } + return key +} + func (s *Service) fetchUsers() ([]XUser, error) { nodeID := s.options.UserNodeID if nodeID == 0 { diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go new file mode 100644 index 00000000..eb51d6c6 --- /dev/null +++ b/service/xboard/service_test.go @@ -0,0 +1,41 @@ +package xboard + +import "testing" + +func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) { + user := XUser{ + UUID: "uuid-value", + Passwd: "passwd-value", + Password: "password-value", + Token: "token-value", + } + + if got := user.ResolveKey(); got != "passwd-value" { + t.Fatalf("ResolveKey() = %q, want %q", got, "passwd-value") + } +} + +func TestResolveUserKeyForSS2022CombinedPassword(t *testing.T) { + service := &Service{ssServerKey: "master-key"} + user := XUser{ + ID: 1, + Password: "master-key:user-key", + UUID: "uuid-value", + } + + if got := service.resolveUserKey(user, true); got != "user-key" { + t.Fatalf("resolveUserKey() = %q, want %q", got, "user-key") + } +} + +func TestResolveUserKeyForNonSS2022UsesResolvedKey(t *testing.T) { + service := &Service{} + user := XUser{ + UUID: "uuid-value", + Passwd: "passwd-value", + } + + if got := service.resolveUserKey(user, false); got != "passwd-value" { + t.Fatalf("resolveUserKey() = %q, want %q", got, "passwd-value") + } +} From e4fdc1791a08e1765cec2160302c1292274bf13d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 11:25:11 +0800 Subject: [PATCH 61/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index e675b8ab..1b127a75 100644 --- a/install.sh +++ b/install.sh @@ -194,21 +194,13 @@ cat > "$CONFIG_FILE" < "$CONFIG_FILE" < Date: Wed, 15 Apr 2026 11:30:21 +0800 Subject: [PATCH 62/97] 1 --- service/xboard/service.go | 21 +++++++++++++++++++-- service/xboard/service_test.go | 12 ++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index c9b80038..13fab8f1 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -902,6 +902,10 @@ func (s *Service) syncUsers() { } for _, u := range users { + userName := u.Identifier() + if userName == "" { + continue + } key := s.resolveUserKey(u, isSS2022) if key == "" { continue @@ -914,9 +918,9 @@ func (s *Service) syncUsers() { s.logger.Info("User [", u.ID, "] ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key) } - newUsers[u.Email] = userData{ + newUsers[userName] = userData{ ID: u.ID, - Email: u.Email, + Email: userName, Key: key, Flow: u.Flow, } @@ -1084,6 +1088,19 @@ type XUser struct { Flow string `json:"flow"` } +func (u *XUser) Identifier() string { + if u.UUID != "" { + return u.UUID + } + if u.Email != "" { + return u.Email + } + if u.ID != 0 { + return strconv.Itoa(u.ID) + } + return "" +} + func (u *XUser) ResolveKey() string { if u.Passwd != "" { return u.Passwd diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index eb51d6c6..16971a33 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -15,6 +15,18 @@ func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) { } } +func TestXUserIdentifierPrefersUUID(t *testing.T) { + user := XUser{ + ID: 7, + UUID: "uuid-value", + Email: "user@example.com", + } + + if got := user.Identifier(); got != "uuid-value" { + t.Fatalf("Identifier() = %q, want %q", got, "uuid-value") + } +} + func TestResolveUserKeyForSS2022CombinedPassword(t *testing.T) { service := &Service{ssServerKey: "master-key"} user := XUser{ From c3a2e852664b84245a4080807a9bcf7346031c9f Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 11:33:58 +0800 Subject: [PATCH 63/97] =?UTF-8?q?=E8=B0=83=E7=94=A8=20inboundManager.Creat?= =?UTF-8?q?e(...)=20=E6=97=B6=E6=8A=8A=E7=AC=AC=E4=BA=8C=E4=B8=AA=E5=8F=82?= =?UTF-8?q?=E6=95=B0=20router=20=E4=BC=A0=E6=88=90=E4=BA=86=20nil=E3=80=82?= =?UTF-8?q?=E4=BD=86=20protocol/shadowsocks/inbound=5Fmulti.go=20=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=97=B6=E4=BC=9A=E6=97=A0=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E6=8A=8A=E8=BF=99=E4=B8=AA=20router=20=E5=8C=85=E8=BF=9B=20uot?= =?UTF-8?q?.NewRouter(router,=20logger)=EF=BC=8C=E6=89=80=E4=BB=A5=20clien?= =?UTF-8?q?t=20=E6=AF=8F=E6=AC=A1=E9=87=8D=E8=BF=9E=E9=83=BD=E4=BC=9A?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E8=A7=A6=E5=8F=91=E4=B8=80=E6=AC=A1=E7=A9=BA?= =?UTF-8?q?=E6=8C=87=E9=92=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 4 +++- service/xboard/service_test.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 13fab8f1..8d478d90 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -72,6 +72,7 @@ type Service struct { reportTicker *time.Ticker aliveTicker *time.Ticker access sync.Mutex + router adapter.Router inboundManager adapter.InboundManager ssCipher string // stored for user key derivation in syncUsers ssServerKey string // stored for SS2022 per-user key extraction @@ -258,6 +259,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio syncTicker: time.NewTicker(time.Duration(options.SyncInterval)), reportTicker: time.NewTicker(time.Duration(options.ReportInterval)), aliveTicker: time.NewTicker(1 * time.Minute), + router: service.FromContext[adapter.Router](ctx), inboundManager: service.FromContext[adapter.InboundManager](ctx), } @@ -759,7 +761,7 @@ func (s *Service) setupNode() error { s.inboundManager.Remove(inboundTag) // Create new inbound - err = s.inboundManager.Create(s.ctx, nil, s.logger, inboundTag, protocol, inboundOptions) + err = s.inboundManager.Create(s.ctx, s.router, s.logger, inboundTag, protocol, inboundOptions) if err != nil { return err } diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index 16971a33..e7ac2e58 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -51,3 +51,18 @@ func TestResolveUserKeyForNonSS2022UsesResolvedKey(t *testing.T) { t.Fatalf("resolveUserKey() = %q, want %q", got, "passwd-value") } } + +func TestXUserIdentifierFallsBackToEmailThenID(t *testing.T) { + userWithEmail := XUser{ + ID: 8, + Email: "user@example.com", + } + if got := userWithEmail.Identifier(); got != "user@example.com" { + t.Fatalf("Identifier() = %q, want %q", got, "user@example.com") + } + + userWithID := XUser{ID: 9} + if got := userWithID.Identifier(); got != "9" { + t.Fatalf("Identifier() = %q, want %q", got, "9") + } +} From 25d939e3a7dd37fb261099bf47f454977e0ac424 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 11:51:34 +0800 Subject: [PATCH 64/97] =?UTF-8?q?=E5=A4=9A=E8=8A=82=E7=82=B9=E8=81=9A?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 112 +++++++++++---- option/xboard.go | 15 ++ protocol/vless/inbound.go | 3 - service/xboard/multi_service.go | 112 +++++++++++++++ service/xboard/service.go | 245 +++++++++++++++++++++++++------- service/xboard/service_test.go | 29 +++- 6 files changed, 436 insertions(+), 80 deletions(-) create mode 100644 service/xboard/multi_service.go diff --git a/install.sh b/install.sh index 1b127a75..aea3ee9f 100644 --- a/install.sh +++ b/install.sh @@ -123,32 +123,106 @@ fi read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL PANEL_URL=${INPUT_URL:-$PANEL_URL} -read -p "Enter Node ID [${NODE_ID}]: " INPUT_ID -NODE_ID=${INPUT_ID:-$NODE_ID} - -read -p "Enter Node Type (e.g., v2ray) [${NODE_TYPE:-v2ray}]: " INPUT_TYPE -NODE_TYPE=${INPUT_TYPE:-${NODE_TYPE:-v2ray}} - read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} -# Consolidation -CONFIG_PANEL_URL=$PANEL_URL -CONFIG_NODE_ID=$NODE_ID -USER_PANEL_URL=$PANEL_URL -USER_NODE_ID=$NODE_ID +read -p "Enter Node Count [${NODE_COUNT:-1}]: " INPUT_COUNT +NODE_COUNT=${INPUT_COUNT:-${NODE_COUNT:-1}} + +if ! [[ "$NODE_COUNT" =~ ^[0-9]+$ ]] || [[ "$NODE_COUNT" -lt 1 ]]; then + echo -e "${RED}Node Count must be a positive integer${NC}" + exit 1 +fi + +declare -a NODE_IDS +declare -a NODE_TYPES +declare -a NODE_TAGS + +for ((i=1; i<=NODE_COUNT; i++)); do + DEFAULT_NODE_ID="" + DEFAULT_NODE_TYPE="${NODE_TYPE:-vless}" + DEFAULT_NODE_TAG="" + if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then + DEFAULT_NODE_ID="$NODE_ID" + fi + read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}]: " INPUT_ID + CURRENT_NODE_ID=${INPUT_ID:-$DEFAULT_NODE_ID} + if [[ -z "$CURRENT_NODE_ID" ]]; then + echo -e "${RED}Node ID is required for node #$i${NC}" + exit 1 + fi + read -p "Enter Node Type for node #$i [${DEFAULT_NODE_TYPE}]: " INPUT_TYPE + CURRENT_NODE_TYPE=${INPUT_TYPE:-$DEFAULT_NODE_TYPE} + read -p "Enter Tag for node #$i (optional) [${DEFAULT_NODE_TAG}]: " INPUT_TAG + CURRENT_NODE_TAG=${INPUT_TAG:-$DEFAULT_NODE_TAG} + NODE_IDS+=("$CURRENT_NODE_ID") + NODE_TYPES+=("$CURRENT_NODE_TYPE") + NODE_TAGS+=("$CURRENT_NODE_TAG") +done # Sync time (Critical for SS 2022) echo -e "${YELLOW}Syncing system time...${NC}" timedatectl set-ntp true || true -if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then +if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then echo -e "${RED}All fields are required!${NC}" exit 1 fi # Clean up trailing slash PANEL_URL="${PANEL_URL%/}" +CONFIG_PANEL_URL=$PANEL_URL +USER_PANEL_URL=$PANEL_URL + +SERVICE_JSON=$(cat < "$CONFIG_FILE" <= 0; i-- { + if err := s.services[i].Close(); err != nil { + return err + } + } + return nil +} diff --git a/service/xboard/service.go b/service/xboard/service.go index 8d478d90..8e4665c5 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -74,23 +74,12 @@ type Service struct { access sync.Mutex router adapter.Router inboundManager adapter.InboundManager + protocol string + vlessFlow string ssCipher string // stored for user key derivation in syncUsers ssServerKey string // stored for SS2022 per-user key extraction } -type XBoardServiceOptions struct { - PanelURL string `json:"panel_url"` - ConfigPanelURL string `json:"config_panel_url,omitempty"` - UserPanelURL string `json:"user_panel_url,omitempty"` - Key string `json:"key"` - NodeID int `json:"node_id"` - ConfigNodeID int `json:"config_node_id,omitempty"` - UserNodeID int `json:"user_node_id,omitempty"` - NodeType string `json:"node_type"` - SyncInterval badoption.Duration `json:"sync_interval,omitempty"` - ReportInterval badoption.Duration `json:"report_interval,omitempty"` -} - type XNodeConfig struct { NodeType string `json:"node_type"` NodeType_ string `json:"nodeType"` @@ -107,6 +96,13 @@ type XNodeConfig struct { Flow string `json:"flow"` TLSSettings *XTLSSettings `json:"tls_settings"` TLSSettings_ *XTLSSettings `json:"tlsSettings"` + PublicKey string `json:"public_key,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + ShortID string `json:"short_id,omitempty"` + ShortIDs []string `json:"short_ids,omitempty"` + Dest string `json:"dest,omitempty"` + ServerName string `json:"server_name,omitempty"` + ServerPortText string `json:"server_port_text,omitempty"` Network string `json:"network"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -124,6 +120,24 @@ type XNodeConfig struct { // AnyTls PaddingScheme []string `json:"padding_scheme"` + + // TLS certificate settings + CertConfig *XCertConfig `json:"cert_config,omitempty"` + AutoTLS bool `json:"auto_tls,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type XCertConfig struct { + CertMode string `json:"cert_mode"` + Domain string `json:"domain"` + Email string `json:"email"` + DNSProvider string `json:"dns_provider"` + DNSEnv map[string]string `json:"dns_env"` + HTTPPort int `json:"http_port"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + CertContent string `json:"cert_content"` + KeyContent string `json:"key_content"` } type XInnerConfig struct { @@ -138,6 +152,12 @@ type XInnerConfig struct { Flow string `json:"flow"` TLSSettings *XTLSSettings `json:"tls_settings"` TLSSettings_ *XTLSSettings `json:"tlsSettings"` + PublicKey string `json:"public_key,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + ShortID string `json:"short_id,omitempty"` + ShortIDs []string `json:"short_ids,omitempty"` + Dest string `json:"dest,omitempty"` + ServerName string `json:"server_name,omitempty"` Network string `json:"network"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -245,6 +265,13 @@ func (s *XStreamSettings) GetReality() *XRealitySettings { } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) { + if len(options.Nodes) > 0 { + return newMultiNodeService(ctx, logger, tag, options) + } + return newSingleService(ctx, logger, tag, options) +} + +func newSingleService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) { ctx, cancel := context.WithCancel(ctx) s := &Service{ Adapter: boxService.NewAdapter(C.TypeXBoard, tag), @@ -277,6 +304,105 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio return s, nil } +func (s *Service) inboundTag() string { + if s.Tag() != "" { + return "xboard-inbound-" + s.Tag() + } + if s.options.NodeID != 0 { + return "xboard-inbound-" + strconv.Itoa(s.options.NodeID) + } + return "xboard-inbound" +} + +func applyCertConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig) bool { + if certConfig == nil { + return false + } + switch certConfig.CertMode { + case "", "none": + return false + case "file": + if certConfig.CertFile == "" || certConfig.KeyFile == "" { + return false + } + tlsOptions.CertificatePath = certConfig.CertFile + tlsOptions.KeyPath = certConfig.KeyFile + return true + case "content": + if certConfig.CertContent == "" || certConfig.KeyContent == "" { + return false + } + tlsOptions.Certificate = badoption.Listable[string]{certConfig.CertContent} + tlsOptions.Key = badoption.Listable[string]{certConfig.KeyContent} + return true + default: + return false + } +} + +func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings { + tlsSettings := inner.TLSSettings + if tlsSettings == nil { + tlsSettings = inner.TLSSettings_ + } + if tlsSettings == nil && config != nil { + tlsSettings = config.TLSSettings + } + if tlsSettings == nil && config != nil { + tlsSettings = config.TLSSettings_ + } + if tlsSettings == nil { + tlsSettings = &XTLSSettings{} + } + + if tlsSettings.PublicKey == "" { + if inner.PublicKey != "" { + tlsSettings.PublicKey = inner.PublicKey + } else if config != nil { + tlsSettings.PublicKey = config.PublicKey + } + } + if tlsSettings.PrivateKey == "" { + if inner.PrivateKey != "" { + tlsSettings.PrivateKey = inner.PrivateKey + } else if config != nil { + tlsSettings.PrivateKey = config.PrivateKey + } + } + if tlsSettings.ShortID == "" { + if inner.ShortID != "" { + tlsSettings.ShortID = inner.ShortID + } else if config != nil { + tlsSettings.ShortID = config.ShortID + } + } + if len(tlsSettings.ShortIDs) == 0 { + if len(inner.ShortIDs) > 0 { + tlsSettings.ShortIDs = inner.ShortIDs + } else if config != nil && len(config.ShortIDs) > 0 { + tlsSettings.ShortIDs = config.ShortIDs + } + } + if tlsSettings.Dest == "" { + if inner.Dest != "" { + tlsSettings.Dest = inner.Dest + } else if config != nil { + tlsSettings.Dest = config.Dest + } + } + if tlsSettings.ServerName == "" { + if inner.ServerName != "" { + tlsSettings.ServerName = inner.ServerName + } else if config != nil { + tlsSettings.ServerName = config.ServerName + } + } + if tlsSettings.ServerPort == "" && config != nil && config.ServerPortText != "" { + tlsSettings.ServerPort = config.ServerPortText + } + return tlsSettings +} + func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil @@ -393,7 +519,7 @@ func (s *Service) setupNode() error { return err } - inboundTag := "xboard-inbound" + inboundTag := s.inboundTag() // Resolve nested config (V2bX compatibility: server_config / serverConfig / config) var inner XInnerConfig @@ -472,6 +598,8 @@ func (s *Service) setupNode() error { } s.logger.Info("Xboard protocol identified: ", protocol) + s.protocol = protocol + s.vlessFlow = inner.Flow var listenAddr badoption.Addr if addr, err := netip.ParseAddr(inner.ListenIP); err == nil { @@ -489,51 +617,53 @@ func (s *Service) setupNode() error { // V2bX: 0=None, 1=TLS, 2=Reality var tlsOptions option.InboundTLSOptions securityType := inner.TLS - tlsSettings := inner.TLSSettings - if tlsSettings == nil { - tlsSettings = inner.TLSSettings_ + tlsSettings := mergedTLSSettings(inner, config) + hasCertificate := applyCertConfig(&tlsOptions, config.CertConfig) + if config.CertConfig != nil && !hasCertificate && config.CertConfig.CertMode != "" && config.CertConfig.CertMode != "none" { + s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", config.CertConfig.CertMode) } switch securityType { case 1: // TLS - tlsOptions.Enabled = true if tlsSettings != nil { tlsOptions.ServerName = tlsSettings.ServerName } + tlsOptions.Enabled = hasCertificate case 2: // Reality - if tlsSettings != nil { - tlsOptions.Enabled = true - tlsOptions.ServerName = tlsSettings.ServerName - shortIDs := tlsSettings.ShortIDs - if len(shortIDs) == 0 && tlsSettings.ShortID != "" { - shortIDs = []string{tlsSettings.ShortID} - } - dest := tlsSettings.Dest - if dest == "" { - dest = tlsSettings.ServerName - } - if dest == "" { - dest = "www.microsoft.com" - } - serverPort := uint16(443) - if tlsSettings.ServerPort != "" { - if port, err := strconv.Atoi(tlsSettings.ServerPort); err == nil && port > 0 { - serverPort = uint16(port) - } - } - tlsOptions.Reality = &option.InboundRealityOptions{ - Enabled: true, - Handshake: option.InboundRealityHandshakeOptions{ - ServerOptions: option.ServerOptions{ - Server: dest, - ServerPort: serverPort, - }, - }, - PrivateKey: tlsSettings.PrivateKey, - ShortID: badoption.Listable[string](shortIDs), - } - s.logger.Info("Xboard REALITY configured. Dest: ", dest, ":", serverPort) + tlsOptions.Enabled = true + tlsOptions.ServerName = tlsSettings.ServerName + shortIDs := tlsSettings.ShortIDs + if len(shortIDs) == 0 && tlsSettings.ShortID != "" { + shortIDs = []string{tlsSettings.ShortID} } + dest := tlsSettings.Dest + if dest == "" { + dest = tlsSettings.ServerName + } + if dest == "" { + dest = "www.microsoft.com" + } + serverPort := uint16(443) + if tlsSettings.ServerPort != "" { + if port, err := strconv.Atoi(tlsSettings.ServerPort); err == nil && port > 0 { + serverPort = uint16(port) + } + } + tlsOptions.Reality = &option.InboundRealityOptions{ + Enabled: true, + Handshake: option.InboundRealityHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: dest, + ServerPort: serverPort, + }, + }, + PrivateKey: tlsSettings.PrivateKey, + ShortID: badoption.Listable[string](shortIDs), + } + if tlsSettings.PublicKey != "" { + s.logger.Debug("Xboard REALITY public_key received from panel") + } + s.logger.Info("Xboard REALITY configured. Dest: ", dest, ":", serverPort) } // Also check streamSettings for Reality (legacy Xboard format) @@ -567,6 +697,10 @@ func (s *Service) setupNode() error { } } + if securityType == 1 && !tlsOptions.Enabled { + s.logger.Warn("Xboard TLS enabled by panel but no usable certificate material found; inbound will run without local TLS") + } + // ── Resolve network transport settings (V2bX style) ── networkType := inner.Network networkSettings := inner.NetworkSettings @@ -757,6 +891,10 @@ func (s *Service) setupNode() error { return fmt.Errorf("unsupported protocol: %s", protocol) } + if !tlsOptions.Enabled && securityType == 1 && (protocol == "trojan" || protocol == "tuic" || protocol == "hysteria" || protocol == "hysteria2" || protocol == "anytls") { + s.logger.Warn("Xboard ", protocol, " usually requires local TLS certificates, but no local certificate material was configured") + } + // Remove old if exists s.inboundManager.Remove(inboundTag) @@ -920,11 +1058,16 @@ func (s *Service) syncUsers() { s.logger.Info("User [", u.ID, "] ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key) } + flow := u.Flow + if s.protocol == "vless" && flow == "" { + flow = s.vlessFlow + } + newUsers[userName] = userData{ ID: u.ID, Email: userName, Key: key, - Flow: u.Flow, + Flow: flow, } } diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index e7ac2e58..3b6f52cc 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -1,6 +1,10 @@ package xboard -import "testing" +import ( + "testing" + + "github.com/sagernet/sing-box/option" +) func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) { user := XUser{ @@ -66,3 +70,26 @@ func TestXUserIdentifierFallsBackToEmailThenID(t *testing.T) { t.Fatalf("Identifier() = %q, want %q", got, "9") } } + +func TestExpandNodeOptions(t *testing.T) { + base := option.XBoardServiceOptions{ + PanelURL: "https://panel.example", + Key: "shared-token", + NodeType: "vless", + Nodes: []option.XBoardNodeOptions{ + {NodeID: 1}, + {NodeID: 2, NodeType: "anytls"}, + }, + } + + nodes := expandNodeOptions(base) + if len(nodes) != 2 { + t.Fatalf("expandNodeOptions() len = %d, want 2", len(nodes)) + } + if nodes[0].NodeID != 1 || nodes[0].NodeType != "vless" { + t.Fatalf("first node = %+v", nodes[0]) + } + if nodes[1].NodeID != 2 || nodes[1].NodeType != "anytls" { + t.Fatalf("second node = %+v", nodes[1]) + } +} From 295e42ec8e4a7d56f4f4678fefa621403db8c2ef Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 11:56:41 +0800 Subject: [PATCH 65/97] =?UTF-8?q?=E4=BD=A0=E4=B8=8B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E5=9C=A8=E6=B5=8B=E8=AF=95=E6=9C=BA=E4=B8=8A=E6=9C=80=E5=BA=94?= =?UTF-8?q?=E8=AF=A5=E7=9C=8B=E7=9A=84=E6=98=AF=E8=BF=99=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=96=B0=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 8e4665c5..bcaa94e4 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -563,6 +563,9 @@ func (s *Service) setupNode() error { inner.Port = config.ServerPort } } + if inner.Port == 0 { + return fmt.Errorf("missing listen port for protocol %s", protocol) + } if inner.Cipher == "" { inner.Cipher = config.Cipher } @@ -908,7 +911,7 @@ func (s *Service) setupNode() error { s.inboundTags = []string{inboundTag} s.access.Unlock() - s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on port ", inner.Port, " (protocol: ", protocol, ")") + s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")") // Register the new inbound in our managed list inbound, _ := s.inboundManager.Get(inboundTag) From 4d764b2c0538cb3d6827feeb4a391fdad0071329 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 11:59:01 +0800 Subject: [PATCH 66/97] =?UTF-8?q?=E5=B7=B2=E4=BF=AE=E6=8E=89=E8=BF=99?= =?UTF-8?q?=E4=B8=AA=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index bcaa94e4..486e0aa3 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -563,9 +563,6 @@ func (s *Service) setupNode() error { inner.Port = config.ServerPort } } - if inner.Port == 0 { - return fmt.Errorf("missing listen port for protocol %s", protocol) - } if inner.Cipher == "" { inner.Cipher = config.Cipher } @@ -599,6 +596,9 @@ func (s *Service) setupNode() error { s.logger.Error("Xboard setup error: could not identify protocol. Please check debug logs for raw JSON.") return fmt.Errorf("unsupported protocol: empty") } + if inner.Port == 0 { + return fmt.Errorf("missing listen port for protocol %s", protocol) + } s.logger.Info("Xboard protocol identified: ", protocol) s.protocol = protocol From c570a7c5f2e3f588b0fec5992490e4bdf9da5306 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 12:01:54 +0800 Subject: [PATCH 67/97] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 486e0aa3..de17112b 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -557,7 +557,9 @@ func (s *Service) setupNode() error { inner.Protocol = config.NodeType } if inner.Port == 0 { - if config.Port != 0 { + if inner.ServerPort != 0 { + inner.Port = inner.ServerPort + } else if config.Port != 0 { inner.Port = config.Port } else { inner.Port = config.ServerPort From d36e8f5b39e7b5115ef95b248c1f381cb4989dae Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 12:16:26 +0800 Subject: [PATCH 68/97] Xboard config fetched... --- service/xboard/service.go | 79 ++++++++++++++++++++++++++++++++++ service/xboard/service_test.go | 23 ++++++++++ 2 files changed, 102 insertions(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index de17112b..6f1e767c 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -104,6 +104,7 @@ type XNodeConfig struct { ServerName string `json:"server_name,omitempty"` ServerPortText string `json:"server_port_text,omitempty"` Network string `json:"network"` + Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -159,6 +160,7 @@ type XInnerConfig struct { Dest string `json:"dest,omitempty"` ServerName string `json:"server_name,omitempty"` Network string `json:"network"` + Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` StreamSettings json.RawMessage `json:"streamSettings"` @@ -166,6 +168,22 @@ type XInnerConfig struct { DownMbps int `json:"down_mbps"` } +type XMultiplexConfig struct { + Enabled bool `json:"enabled"` + Protocol string `json:"protocol,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + MinStreams int `json:"min_streams,omitempty"` + MaxStreams int `json:"max_streams,omitempty"` + Padding bool `json:"padding,omitempty"` + Brutal *XBrutalConfig `json:"brutal,omitempty"` +} + +type XBrutalConfig struct { + Enabled bool `json:"enabled,omitempty"` + UpMbps int `json:"up_mbps,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` +} + type HttpNetworkConfig struct { Header struct { Type string `json:"type"` @@ -403,6 +421,25 @@ func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings { return tlsSettings } +func buildInboundMultiplex(config *XMultiplexConfig) *option.InboundMultiplexOptions { + if config == nil || !config.Enabled { + return nil + } + var brutal *option.BrutalOptions + if config.Brutal != nil { + brutal = &option.BrutalOptions{ + Enabled: config.Brutal.Enabled, + UpMbps: config.Brutal.UpMbps, + DownMbps: config.Brutal.DownMbps, + } + } + return &option.InboundMultiplexOptions{ + Enabled: config.Enabled, + Padding: config.Padding, + Brutal: brutal, + } +} + func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil @@ -556,14 +593,22 @@ func (s *Service) setupNode() error { if inner.Protocol == "" { inner.Protocol = config.NodeType } + portSource := "" if inner.Port == 0 { if inner.ServerPort != 0 { inner.Port = inner.ServerPort + portSource = "inner.server_port" } else if config.Port != 0 { inner.Port = config.Port + portSource = "config.port" } else { inner.Port = config.ServerPort + if config.ServerPort != 0 { + portSource = "config.server_port" + } } + } else { + portSource = "inner.port" } if inner.Cipher == "" { inner.Cipher = config.Cipher @@ -574,6 +619,9 @@ func (s *Service) setupNode() error { if inner.Network == "" { inner.Network = config.Network } + if inner.Multiplex == nil { + inner.Multiplex = config.Multiplex + } if len(inner.NetworkSettings) == 0 { inner.NetworkSettings = config.NetworkSettings } @@ -714,6 +762,20 @@ func (s *Service) setupNode() error { } // ── Build inbound per protocol (matching V2bX core/sing/node.go) ── + multiplex := buildInboundMultiplex(inner.Multiplex) + s.logger.Info( + "Xboard node config resolved. protocol=", protocol, + ", listen_ip=", inner.ListenIP, + ", listen_port=", inner.Port, + " (source=", portSource, ")", + ", inner_server_port=", inner.ServerPort, + ", config_port=", config.Port, + ", config_server_port=", config.ServerPort, + ", network=", networkType, + ", tls=", securityType, + ", multiplex=", multiplex != nil, + ) + var inboundOptions any switch protocol { case "vmess", "vless": @@ -729,6 +791,7 @@ func (s *Service) setupNode() error { InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, + Multiplex: multiplex, } if transport != nil { opts.Transport = transport @@ -740,6 +803,7 @@ func (s *Service) setupNode() error { InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, + Multiplex: multiplex, } if transport != nil { opts.Transport = transport @@ -765,6 +829,7 @@ func (s *Service) setupNode() error { ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, + Multiplex: multiplex, } isSS2022 := strings.Contains(method, "2022") @@ -805,6 +870,7 @@ func (s *Service) setupNode() error { InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, + Multiplex: multiplex, } if transport != nil { opts.Transport = transport @@ -902,6 +968,7 @@ func (s *Service) setupNode() error { // Remove old if exists s.inboundManager.Remove(inboundTag) + s.logger.Info("Xboard creating inbound [", inboundTag, "] on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")") // Create new inbound err = s.inboundManager.Create(s.ctx, s.router, s.logger, inboundTag, protocol, inboundOptions) @@ -983,6 +1050,12 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { // Try unmarshaling WITHOUT "data" wrapper var flatResult XNodeConfig if err2 := json.Unmarshal(body, &flatResult); err2 == nil { + s.logger.Info( + "Xboard config fetched (flat). protocol=", flatResult.Protocol, + ", node_type=", flatResult.NodeType, + ", port=", flatResult.Port, + ", server_port=", flatResult.ServerPort, + ) return &flatResult, nil } @@ -995,6 +1068,12 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" && result.Data.ServerPort == 0 { s.logger.Error("Xboard config mapping failed (fields missing). Data: ", string(body)) } + s.logger.Info( + "Xboard config fetched. protocol=", result.Data.Protocol, + ", node_type=", result.Data.NodeType, + ", port=", result.Data.Port, + ", server_port=", result.Data.ServerPort, + ) return &result.Data, nil } diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index 3b6f52cc..3f51d183 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -93,3 +93,26 @@ func TestExpandNodeOptions(t *testing.T) { t.Fatalf("second node = %+v", nodes[1]) } } + +func TestBuildInboundMultiplex(t *testing.T) { + config := &XMultiplexConfig{ + Enabled: true, + Padding: true, + Brutal: &XBrutalConfig{ + Enabled: true, + UpMbps: 100, + DownMbps: 200, + }, + } + + got := buildInboundMultiplex(config) + if got == nil { + t.Fatal("buildInboundMultiplex() returned nil") + } + if !got.Enabled || !got.Padding { + t.Fatalf("buildInboundMultiplex() = %+v", got) + } + if got.Brutal == nil || !got.Brutal.Enabled || got.Brutal.UpMbps != 100 || got.Brutal.DownMbps != 200 { + t.Fatalf("buildInboundMultiplex() brutal = %+v", got.Brutal) + } +} From b6a685722aa5907f300df2fc51e4c5691da0fb0a Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 12:22:08 +0800 Subject: [PATCH 69/97] fff --- service/xboard/service.go | 140 ++++++++++++++++++++++----------- service/xboard/service_test.go | 15 ++++ 2 files changed, 108 insertions(+), 47 deletions(-) diff --git a/service/xboard/service.go b/service/xboard/service.go index 6f1e767c..9b19d19c 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -1003,6 +1003,81 @@ func (s *Service) setupNode() error { return nil } +func normalizePanelNodeType(nodeType string) string { + switch strings.ToLower(strings.TrimSpace(nodeType)) { + case "v2ray": + return "vmess" + case "hysteria2": + return "hysteria" + default: + return strings.ToLower(strings.TrimSpace(nodeType)) + } +} + +func (s *Service) panelRequest(method string, baseURL string, endpoint string, nodeID int, payload []byte, contentType string) (http.Header, []byte, int, error) { + nodeType := normalizePanelNodeType(s.options.NodeType) + nodeTypeCandidates := []string{nodeType} + if nodeType != "" { + nodeTypeCandidates = append(nodeTypeCandidates, "") + } + + var lastHeader http.Header + var lastBody []byte + var lastStatus int + for index, candidate := range nodeTypeCandidates { + requestURL, err := url.Parse(strings.TrimRight(baseURL, "/") + endpoint) + if err != nil { + return nil, nil, 0, err + } + query := requestURL.Query() + query.Set("node_id", strconv.Itoa(nodeID)) + query.Set("token", s.options.Key) + if candidate != "" { + query.Set("node_type", candidate) + } + requestURL.RawQuery = query.Encode() + + var bodyReader io.Reader + if payload != nil { + bodyReader = bytes.NewReader(payload) + } + req, _ := http.NewRequest(method, requestURL.String(), bodyReader) + req.Header.Set("User-Agent", "sing-box/xboard") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + logNodeType := candidate + if logNodeType == "" { + logNodeType = "" + } + s.logger.Info("Xboard panel request. endpoint=", endpoint, ", node_id=", nodeID, ", node_type=", logNodeType) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, nil, 0, err + } + responseBody, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, nil, 0, readErr + } + + lastHeader = resp.Header.Clone() + lastBody = responseBody + lastStatus = resp.StatusCode + + if resp.StatusCode == 400 && candidate != "" && strings.Contains(string(responseBody), "Server does not exist") && index+1 < len(nodeTypeCandidates) { + s.logger.Warn("Xboard panel request failed with node_type=", candidate, ", retrying without node_type") + continue + } + + return lastHeader, lastBody, lastStatus, nil + } + + return lastHeader, lastBody, lastStatus, nil +} + func (s *Service) fetchConfig() (*XNodeConfig, error) { nodeID := s.options.ConfigNodeID if nodeID == 0 { @@ -1012,18 +1087,13 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { if baseURL == "" { baseURL = s.options.PanelURL } - url := fmt.Sprintf("%s/api/v1/server/UniProxy/config?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "sing-box/xboard") - - resp, err := s.httpClient.Do(req) + headers, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/config", nodeID, nil, "") if err != nil { return nil, err } - defer resp.Body.Close() // Check time drift - if dateStr := resp.Header.Get("Date"); dateStr != "" { + if dateStr := headers.Get("Date"); dateStr != "" { if panelTime, err := http.ParseTime(dateStr); err == nil { localTime := time.Now() drift := localTime.Sub(panelTime) @@ -1035,13 +1105,10 @@ func (s *Service) fetchConfig() (*XNodeConfig, error) { } } - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - return nil, E.New("failed to fetch config, status: ", resp.Status, ", body: ", string(respBody)) + if statusCode != 200 { + return nil, E.New("failed to fetch config, status: ", statusCode, ", body: ", string(body)) } - body, _ := io.ReadAll(resp.Body) - var result struct { Data XNodeConfig `json:"data"` } @@ -1249,22 +1316,15 @@ func (s *Service) pushTraffic(data any) error { if baseURL == "" { baseURL = s.options.PanelURL } - url := fmt.Sprintf("%s/api/v1/server/UniProxy/push?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) body, _ := json.Marshal(data) - - req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "sing-box/xboard") - - resp, err := s.httpClient.Do(req) + + _, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/push", nodeID, body, "application/json") if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - return E.New("failed to push traffic, status: ", resp.Status, ", body: ", string(respBody)) + + if statusCode != 200 { + return E.New("failed to push traffic, status: ", statusCode, ", body: ", string(responseBody)) } return nil } @@ -1278,21 +1338,15 @@ func (s *Service) sendAlive() { if baseURL == "" { baseURL = s.options.PanelURL } - url := fmt.Sprintf("%s/api/v1/server/UniProxy/alive?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) - - req, _ := http.NewRequest("POST", url, nil) - req.Header.Set("User-Agent", "sing-box/xboard") - - resp, err := s.httpClient.Do(req) + + _, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/alive", nodeID, nil, "") if err != nil { s.logger.Error("Xboard heartbeat error: ", err) return } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - s.logger.Warn("Xboard heartbeat failed, status: ", resp.Status, ", body: ", string(respBody)) + + if statusCode != 200 { + s.logger.Warn("Xboard heartbeat failed, status: ", statusCode, ", body: ", string(responseBody)) } else { s.logger.Trace("Xboard heartbeat sent") } @@ -1369,23 +1423,15 @@ func (s *Service) fetchUsers() ([]XUser, error) { if baseURL == "" { baseURL = s.options.PanelURL } - url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "sing-box/xboard") - - resp, err := s.httpClient.Do(req) + _, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/user", nodeID, nil, "") if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - return nil, E.New("failed to fetch users, status: ", resp.Status, ", body: ", string(respBody)) + + if statusCode != 200 { + return nil, E.New("failed to fetch users, status: ", statusCode, ", body: ", string(body)) } - body, _ := io.ReadAll(resp.Body) - var result struct { Data []XUser `json:"data"` Users []XUser `json:"users"` diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index 3f51d183..5d805d91 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -116,3 +116,18 @@ func TestBuildInboundMultiplex(t *testing.T) { t.Fatalf("buildInboundMultiplex() brutal = %+v", got.Brutal) } } + +func TestNormalizePanelNodeType(t *testing.T) { + tests := map[string]string{ + "v2ray": "vmess", + "hysteria2": "hysteria", + "vless": "vless", + "": "", + } + + for input, want := range tests { + if got := normalizePanelNodeType(input); got != want { + t.Fatalf("normalizePanelNodeType(%q) = %q, want %q", input, got, want) + } + } +} From 5b4723ca11fb8da123cce45c92f0948b09c342dc Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 13:46:38 +0800 Subject: [PATCH 70/97] =?UTF-8?q?=E9=A1=B6=E9=A1=B6=E9=A1=B6=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/xboard/service.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/service/xboard/service.go b/service/xboard/service.go index 9b19d19c..05b49970 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -590,6 +590,9 @@ func (s *Service) setupNode() error { if inner.Flow == "" { inner.Flow = config.Flow } + if inner.Protocol == "" { + inner.Protocol = config.Protocol + } if inner.Protocol == "" { inner.Protocol = config.NodeType } @@ -631,6 +634,9 @@ func (s *Service) setupNode() error { // Resolve protocol protocol := inner.Protocol + if protocol == "" { + protocol = config.Protocol + } if protocol == "" { protocol = inner.NodeType } From d188a2060b7076c265d645344e5f1b8902a73c7e Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 13:55:00 +0800 Subject: [PATCH 71/97] =?UTF-8?q?=E5=A6=82=E6=9E=9C=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=B2=A1=E8=BF=94=E5=9B=9E=20server=5Fname=EF=BC=8C=E4=BD=86?= =?UTF-8?q?=E8=BF=98=E4=BF=9D=E7=95=99=E4=BA=86=20Vision=20flow=EF=BC=8C?= =?UTF-8?q?=E4=B9=9F=E4=BC=9A=E6=89=93=E8=AD=A6=E5=91=8A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- protocol/vless/inbound.go | 12 ++++++------ service/xboard/service.go | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 40b6f72f..19d40724 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -194,12 +194,6 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() - if tracker != nil { - conn = tracker.TrackConnection(conn, metadata) - } err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) @@ -222,6 +216,12 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a metadata.User = user } h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } h.router.RouteConnectionEx(ctx, conn, metadata, onClose) } diff --git a/service/xboard/service.go b/service/xboard/service.go index 05b49970..d5b28475 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -76,6 +76,7 @@ type Service struct { inboundManager adapter.InboundManager protocol string vlessFlow string + vlessServerName string ssCipher string // stored for user key derivation in syncUsers ssServerKey string // stored for SS2022 per-user key extraction } @@ -658,7 +659,8 @@ func (s *Service) setupNode() error { s.logger.Info("Xboard protocol identified: ", protocol) s.protocol = protocol - s.vlessFlow = inner.Flow + s.vlessFlow = "" + s.vlessServerName = "" var listenAddr badoption.Addr if addr, err := netip.ParseAddr(inner.ListenIP); err == nil { @@ -677,6 +679,9 @@ func (s *Service) setupNode() error { var tlsOptions option.InboundTLSOptions securityType := inner.TLS tlsSettings := mergedTLSSettings(inner, config) + if tlsSettings != nil && tlsSettings.ServerName != "" { + s.vlessServerName = tlsSettings.ServerName + } hasCertificate := applyCertConfig(&tlsOptions, config.CertConfig) if config.CertConfig != nil && !hasCertificate && config.CertConfig.CertMode != "" && config.CertConfig.CertMode != "none" { s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", config.CertConfig.CertMode) @@ -792,6 +797,17 @@ func (s *Service) setupNode() error { } if protocol == "vless" { + if tlsSettings != nil && tlsSettings.ServerName != "" { + s.logger.Info("Xboard VLESS server_name from panel: ", tlsSettings.ServerName) + } + resolvedFlow := inner.Flow + if resolvedFlow == "xtls-rprx-vision" { + if !tlsOptions.Enabled || (transport != nil && transport.Type != "") { + s.logger.Warn("Xboard VLESS flow xtls-rprx-vision ignored because inbound is not raw TLS/REALITY over TCP") + resolvedFlow = "" + } + } + s.vlessFlow = resolvedFlow opts := &option.VLESSInboundOptions{ ListenOptions: listen, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ @@ -1219,6 +1235,9 @@ func (s *Service) syncUsers() { if s.protocol == "vless" && flow == "" { flow = s.vlessFlow } + if s.protocol == "vless" && flow == "xtls-rprx-vision" && s.vlessServerName == "" { + s.logger.Warn("Xboard VLESS flow xtls-rprx-vision kept but panel did not provide server_name") + } newUsers[userName] = userData{ ID: u.ID, From d82b7b1ca6cf0d21b03bc293fda5cbe52225ac34 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 14:17:32 +0800 Subject: [PATCH 72/97] =?UTF-8?q?=E5=87=BA=E9=97=AE=E9=A2=98=E9=82=A3?= =?UTF-8?q?=E4=B8=AA=E7=94=A8=E6=88=B7=E5=AF=B9=E5=BA=94=E7=9A=84=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E8=AE=A2=E9=98=85=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 9 +-------- option/xboard.go | 2 +- service/xboard/service.go | 8 ++++---- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/install.sh b/install.sh index aea3ee9f..14b3dac2 100644 --- a/install.sh +++ b/install.sh @@ -135,12 +135,10 @@ if ! [[ "$NODE_COUNT" =~ ^[0-9]+$ ]] || [[ "$NODE_COUNT" -lt 1 ]]; then fi declare -a NODE_IDS -declare -a NODE_TYPES declare -a NODE_TAGS for ((i=1; i<=NODE_COUNT; i++)); do DEFAULT_NODE_ID="" - DEFAULT_NODE_TYPE="${NODE_TYPE:-vless}" DEFAULT_NODE_TAG="" if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then DEFAULT_NODE_ID="$NODE_ID" @@ -151,12 +149,9 @@ for ((i=1; i<=NODE_COUNT; i++)); do echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi - read -p "Enter Node Type for node #$i [${DEFAULT_NODE_TYPE}]: " INPUT_TYPE - CURRENT_NODE_TYPE=${INPUT_TYPE:-$DEFAULT_NODE_TYPE} read -p "Enter Tag for node #$i (optional) [${DEFAULT_NODE_TAG}]: " INPUT_TAG CURRENT_NODE_TAG=${INPUT_TAG:-$DEFAULT_NODE_TAG} NODE_IDS+=("$CURRENT_NODE_ID") - NODE_TYPES+=("$CURRENT_NODE_TYPE") NODE_TAGS+=("$CURRENT_NODE_TAG") done @@ -190,7 +185,6 @@ if [[ "$NODE_COUNT" -eq 1 ]]; then SERVICE_JSON+=$(cat < aliveIPRetention { + delete(ipSet, ip) + continue + } + activeIPs = append(activeIPs, ip) + } + if len(ipSet) == 0 { + delete(s.aliveUsers, userName) + } + if len(activeIPs) == 0 { + continue + } + + sort.Strings(activeIPs) + payload[strconv.Itoa(userMeta.ID)] = activeIPs + } + + return payload +} From 27ed827903a057ccbd710048d23f50002e27077e Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 14:43:23 +0800 Subject: [PATCH 75/97] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 14b3dac2..e3dbcc38 100644 --- a/install.sh +++ b/install.sh @@ -15,9 +15,12 @@ NC='\033[0m' CONFIG_DIR="/etc/sing-box" CONFIG_FILE="$CONFIG_DIR/config.json" BINARY_PATH="/usr/local/bin/sing-box" -SERVICE_FILE="/etc/systemd/system/sing-box.service" +SERVICE_NAME="ganclient" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +LEGACY_SERVICE_NAME="sing-box" +LEGACY_SERVICE_FILE="/etc/systemd/system/${LEGACY_SERVICE_NAME}.service" -echo -e "${GREEN}Welcome to sing-box Xboard Installation Script${NC}" +echo -e "${GREEN}Welcome to ganclient Installation Script${NC}" # Check root if [[ $EUID -ne 0 ]]; then @@ -113,6 +116,22 @@ build_sing_box() { install_go build_sing_box +cleanup_legacy_service() { + echo -e "${YELLOW}Cleaning up legacy sing-box service if present...${NC}" + if systemctl list-unit-files | grep -q "^${LEGACY_SERVICE_NAME}\.service"; then + systemctl stop "${LEGACY_SERVICE_NAME}" 2>/dev/null || true + systemctl disable "${LEGACY_SERVICE_NAME}" 2>/dev/null || true + fi + if [[ -f "$LEGACY_SERVICE_FILE" ]]; then + rm -f "$LEGACY_SERVICE_FILE" + fi + if [[ -L "/etc/systemd/system/multi-user.target.wants/${LEGACY_SERVICE_NAME}.service" ]]; then + rm -f "/etc/systemd/system/multi-user.target.wants/${LEGACY_SERVICE_NAME}.service" + fi +} + +cleanup_legacy_service + # Load .env if exists if [[ -f ".env" ]]; then echo -e "${YELLOW}Loading configuration from .env...${NC}" @@ -269,7 +288,7 @@ echo -e "${GREEN}Configuration written to $CONFIG_FILE${NC}" echo -e "${YELLOW}Creating systemd service...${NC}" cat > "$SERVICE_FILE" < Date: Wed, 15 Apr 2026 15:40:38 +0800 Subject: [PATCH 76/97] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=83=AD=E9=87=8D?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api.md | 22 ++- common/listener/listener_tcp.go | 16 +- common/listener/proxy_protocol.go | 186 +++++++++++++++++++++++ common/listener/proxy_protocol_test.go | 114 ++++++++++++++ install.sh | 15 ++ protocol/shadowsocks/inbound_multi.go | 34 ++++- protocol/vless/inbound.go | 28 +++- protocol/vmess/inbound.go | 24 ++- service/xboard/service.go | 199 +++++++++++++++++++++++++ 9 files changed, 614 insertions(+), 24 deletions(-) create mode 100644 common/listener/proxy_protocol.go create mode 100644 common/listener/proxy_protocol_test.go diff --git a/api.md b/api.md index 50167aaa..3f72f411 100644 --- a/api.md +++ b/api.md @@ -331,6 +331,26 @@ Same endpoints as V1 but under `/api/v2/passport/` prefix. - **Purpose**: Get server configuration - **Returns**: Server configuration - **Data**: Server settings and parameters + - **Example Response**: +```json +{ + "protocol": "vless", + "listen_ip": "0.0.0.0", + "server_port": 18443, + "network": "tcp", + "tls": 2, + "server_name": "git.example.com", + "dest": "www.cloudflare.com:443", + "private_key": "YOUR_REALITY_PRIVATE_KEY", + "short_id": "01234567", + "accept_proxy_protocol": true, + "base_config": { + "push_interval": 60, + "pull_interval": 60 + } +} +``` + - **Proxy Protocol Note**: Set `accept_proxy_protocol` to `true` only when this node is behind an L4 proxy or load balancer that really sends PROXY protocol headers. Direct client connections will fail if this is enabled without an upstream PROXY sender. - **GET** `/api/v1/server/UniProxy/user` - **Purpose**: Get user data for server @@ -952,4 +972,4 @@ Same endpoints as V1 but under `/api/v2/passport/` prefix. 6. **Filtering**: Admin endpoints often support filtering and sorting parameters. -This documentation provides a comprehensive overview of all available API endpoints in the Xboard system. Each endpoint serves specific functionality within the VPN service management platform. \ No newline at end of file +This documentation provides a comprehensive overview of all available API endpoints in the Xboard system. Each endpoint serves specific functionality within the VPN service management platform. diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index 54d84a6b..8fa2948e 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -21,10 +21,6 @@ import ( ) func (l *Listener) ListenTCP() (net.Listener, error) { - //nolint:staticcheck - if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { - return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") - } var err error bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) var listenConfig net.ListenConfig @@ -100,6 +96,18 @@ func (l *Listener) loopTCPIn() { l.logger.Error("tcp listener closed: ", err) continue } + remoteAddr := conn.RemoteAddr() + //nolint:staticcheck + if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { + //nolint:staticcheck + wrappedConn, wrapErr := wrapProxyProtocolConn(conn, l.listenOptions.ProxyProtocolAcceptNoHeader) + if wrapErr != nil { + conn.Close() + l.logger.Error("process connection from ", remoteAddr, ": PROXY protocol: ", wrapErr) + continue + } + conn = wrappedConn + } //nolint:staticcheck metadata.InboundDetour = l.listenOptions.Detour metadata.Source = M.SocksaddrFromNet(conn.RemoteAddr()).Unwrap() diff --git a/common/listener/proxy_protocol.go b/common/listener/proxy_protocol.go new file mode 100644 index 00000000..01ec1da4 --- /dev/null +++ b/common/listener/proxy_protocol.go @@ -0,0 +1,186 @@ +package listener + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" +) + +var errProxyProtocolHeaderNotPresent = errors.New("proxy protocol header not present") + +var proxyProtocolV2Signature = []byte{ + 0x0d, 0x0a, 0x0d, 0x0a, + 0x00, 0x0d, 0x0a, 0x51, + 0x55, 0x49, 0x54, 0x0a, +} + +type proxyProtocolConn struct { + net.Conn + reader *bufio.Reader + remoteAddr net.Addr +} + +func (c *proxyProtocolConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *proxyProtocolConn) RemoteAddr() net.Addr { + return c.remoteAddr +} + +func wrapProxyProtocolConn(conn net.Conn, allowNoHeader bool) (net.Conn, error) { + reader := bufio.NewReader(conn) + remoteAddr, err := readProxyProtocolRemoteAddr(reader) + if err != nil { + if allowNoHeader && errors.Is(err, errProxyProtocolHeaderNotPresent) { + return &proxyProtocolConn{ + Conn: conn, + reader: reader, + remoteAddr: conn.RemoteAddr(), + }, nil + } + return nil, err + } + if remoteAddr == nil { + remoteAddr = conn.RemoteAddr() + } + return &proxyProtocolConn{ + Conn: conn, + reader: reader, + remoteAddr: remoteAddr, + }, nil +} + +func readProxyProtocolRemoteAddr(reader *bufio.Reader) (net.Addr, error) { + firstByte, err := reader.Peek(1) + if err != nil { + return nil, err + } + switch firstByte[0] { + case 'P': + return readProxyProtocolV1RemoteAddr(reader) + case '\r': + signature, err := reader.Peek(len(proxyProtocolV2Signature)) + if err != nil { + return nil, err + } + if !bytes.Equal(signature, proxyProtocolV2Signature) { + return nil, errProxyProtocolHeaderNotPresent + } + return readProxyProtocolV2RemoteAddr(reader) + default: + return nil, errProxyProtocolHeaderNotPresent + } +} + +func readProxyProtocolV1RemoteAddr(reader *bufio.Reader) (net.Addr, error) { + prefix, err := reader.Peek(6) + if err != nil { + return nil, err + } + if !bytes.Equal(prefix, []byte("PROXY ")) { + return nil, errProxyProtocolHeaderNotPresent + } + + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + if len(line) < 2 || line[len(line)-2:] != "\r\n" { + return nil, fmt.Errorf("invalid PROXY protocol v1 line ending") + } + + fields := bytes.Fields([]byte(line[:len(line)-2])) + if len(fields) < 2 { + return nil, fmt.Errorf("invalid PROXY protocol v1 header") + } + if string(fields[1]) == "UNKNOWN" { + return nil, nil + } + if len(fields) != 6 { + return nil, fmt.Errorf("invalid PROXY protocol v1 field count") + } + + sourceIP := net.ParseIP(string(fields[2])) + if sourceIP == nil { + return nil, fmt.Errorf("invalid PROXY protocol source ip") + } + sourcePort, err := parseProxyProtocolPort(fields[4]) + if err != nil { + return nil, err + } + return &net.TCPAddr{ + IP: sourceIP, + Port: sourcePort, + }, nil +} + +func readProxyProtocolV2RemoteAddr(reader *bufio.Reader) (net.Addr, error) { + header := make([]byte, 16) + if _, err := io.ReadFull(reader, header); err != nil { + return nil, err + } + if !bytes.Equal(header[:12], proxyProtocolV2Signature) { + return nil, errProxyProtocolHeaderNotPresent + } + + version := header[12] >> 4 + command := header[12] & 0x0f + if version != 0x2 { + return nil, fmt.Errorf("invalid PROXY protocol v2 version") + } + + addressDataLen := int(binary.BigEndian.Uint16(header[14:16])) + addressData := make([]byte, addressDataLen) + if _, err := io.ReadFull(reader, addressData); err != nil { + return nil, err + } + + if command == 0x0 { + return nil, nil + } + if command != 0x1 { + return nil, fmt.Errorf("unsupported PROXY protocol v2 command") + } + + switch header[13] { + case 0x11, 0x12: + if len(addressData) < 12 { + return nil, fmt.Errorf("short PROXY protocol v2 ipv4 header") + } + return &net.TCPAddr{ + IP: net.IP(addressData[:4]), + Port: int(binary.BigEndian.Uint16(addressData[8:10])), + }, nil + case 0x21, 0x22: + if len(addressData) < 36 { + return nil, fmt.Errorf("short PROXY protocol v2 ipv6 header") + } + return &net.TCPAddr{ + IP: net.IP(addressData[:16]), + Port: int(binary.BigEndian.Uint16(addressData[32:34])), + }, nil + case 0x31, 0x32: + return nil, nil + default: + return nil, fmt.Errorf("unsupported PROXY protocol v2 family") + } +} + +func parseProxyProtocolPort(raw []byte) (int, error) { + port := 0 + for _, ch := range raw { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("invalid PROXY protocol port") + } + port = port*10 + int(ch-'0') + if port > 65535 { + return 0, fmt.Errorf("invalid PROXY protocol port") + } + } + return port, nil +} diff --git a/common/listener/proxy_protocol_test.go b/common/listener/proxy_protocol_test.go new file mode 100644 index 00000000..61134d29 --- /dev/null +++ b/common/listener/proxy_protocol_test.go @@ -0,0 +1,114 @@ +package listener + +import ( + "bufio" + "encoding/binary" + "net" + "strings" + "testing" + "time" +) + +func TestReadProxyProtocolV1RemoteAddr(t *testing.T) { + reader := bufio.NewReaderSize(newStaticConn("PROXY TCP4 203.0.113.10 192.0.2.1 45678 443\r\npayload"), 128) + remoteAddr, err := readProxyProtocolRemoteAddr(reader) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tcpAddr, ok := remoteAddr.(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected addr type: %T", remoteAddr) + } + if got := tcpAddr.IP.String(); got != "203.0.113.10" { + t.Fatalf("unexpected ip: %s", got) + } + if tcpAddr.Port != 45678 { + t.Fatalf("unexpected port: %d", tcpAddr.Port) + } +} + +func TestReadProxyProtocolV2RemoteAddr(t *testing.T) { + header := make([]byte, 28) + copy(header[:12], proxyProtocolV2Signature) + header[12] = 0x21 + header[13] = 0x11 + binary.BigEndian.PutUint16(header[14:16], 12) + copy(header[16:20], net.ParseIP("198.51.100.12").To4()) + copy(header[20:24], net.ParseIP("192.0.2.8").To4()) + binary.BigEndian.PutUint16(header[24:26], 50000) + binary.BigEndian.PutUint16(header[26:28], 443) + + reader := bufio.NewReaderSize(newStaticConn(string(header)+"payload"), 128) + remoteAddr, err := readProxyProtocolRemoteAddr(reader) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tcpAddr, ok := remoteAddr.(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected addr type: %T", remoteAddr) + } + if got := tcpAddr.IP.String(); got != "198.51.100.12" { + t.Fatalf("unexpected ip: %s", got) + } + if tcpAddr.Port != 50000 { + t.Fatalf("unexpected port: %d", tcpAddr.Port) + } +} + +func TestWrapProxyProtocolConnAllowNoHeader(t *testing.T) { + rawConn := newStaticConn("hello") + conn, err := wrapProxyProtocolConn(rawConn, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn.RemoteAddr().String() != rawConn.RemoteAddr().String() { + t.Fatalf("remote addr changed unexpectedly: %s", conn.RemoteAddr()) + } +} + +type staticConn struct { + net.Conn + reader *bufio.Reader + local net.Addr + remote net.Addr +} + +func newStaticConn(payload string) *staticConn { + return &staticConn{ + reader: bufio.NewReaderSize(strings.NewReader(payload), len(payload)+16), + local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}, + remote: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345}, + } +} + +func (c *staticConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *staticConn) Write(p []byte) (int, error) { + return len(p), nil +} + +func (c *staticConn) Close() error { + return nil +} + +func (c *staticConn) LocalAddr() net.Addr { + return c.local +} + +func (c *staticConn) RemoteAddr() net.Addr { + return c.remote +} + +func (c *staticConn) SetDeadline(t time.Time) error { + return nil +} + +func (c *staticConn) SetReadDeadline(t time.Time) error { + return nil +} + +func (c *staticConn) SetWriteDeadline(t time.Time) error { + return nil +} diff --git a/install.sh b/install.sh index e3dbcc38..f8dacda2 100644 --- a/install.sh +++ b/install.sh @@ -145,6 +145,9 @@ PANEL_URL=${INPUT_URL:-$PANEL_URL} read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} +read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL +ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}} + read -p "Enter Node Count [${NODE_COUNT:-1}]: " INPUT_COUNT NODE_COUNT=${INPUT_COUNT:-${NODE_COUNT:-1}} @@ -284,6 +287,17 @@ EOF echo -e "${GREEN}Configuration written to $CONFIG_FILE${NC}" +if [[ "$ENABLE_PROXY_PROTOCOL_HINT" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then + echo -e "${YELLOW}Proxy Protocol deployment hint enabled.${NC}" + echo -e "${YELLOW}To make real client IP reporting work, your panel node config response must include:${NC}" + echo -e "${YELLOW} \"accept_proxy_protocol\": true${NC}" + echo -e "${YELLOW}Only enable this when the upstream L4 proxy or load balancer actually sends PROXY protocol headers.${NC}" + echo -e "${YELLOW}If clients connect directly without a PROXY header, connections will fail after enabling it on the panel.${NC}" +else + echo -e "${YELLOW}Proxy Protocol is not expected for this deployment.${NC}" + echo -e "${YELLOW}Keep panel field \"accept_proxy_protocol\" disabled or absent unless you are using an L4 proxy/LB that sends it.${NC}" +fi + # Create Systemd Service echo -e "${YELLOW}Creating systemd service...${NC}" cat > "$SERVICE_FILE" <= len(h.users) { + h.ssmMutex.RUnlock() + return os.ErrInvalid + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -175,8 +189,8 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat //nolint:staticcheck metadata.InboundDetour = h.listener.ListenOptions().Detour //nolint:staticcheck - if h.tracker != nil { - conn = h.tracker.TrackConnection(conn, metadata) + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) } return h.router.RouteConnection(ctx, conn, metadata) } @@ -186,7 +200,15 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon if !loaded { return os.ErrInvalid } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + return os.ErrInvalid + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -200,8 +222,8 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon //nolint:staticcheck metadata.InboundDetour = h.listener.ListenOptions().Detour //nolint:staticcheck - if h.tracker != nil { - conn = h.tracker.TrackPacketConnection(conn, metadata) + if tracker != nil { + conn = tracker.TrackPacketConnection(conn, metadata) } return h.router.RoutePacketConnection(ctx, conn, metadata) } diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 19d40724..6635abe2 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -209,16 +209,22 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { metadata.User = user } h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() if tracker != nil { conn = tracker.TrackConnection(conn, metadata) } @@ -233,7 +239,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -246,9 +261,6 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() if tracker != nil { conn = tracker.TrackPacketConnection(conn, metadata) } diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index e3f4a388..12125d7b 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -215,7 +215,15 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -233,7 +241,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -246,9 +263,6 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() if tracker != nil { conn = tracker.TrackPacketConnection(conn, metadata) } diff --git a/service/xboard/service.go b/service/xboard/service.go index 5c35f265..5d695074 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -106,6 +106,8 @@ type XNodeConfig struct { ServerName string `json:"server_name,omitempty"` ServerPortText string `json:"server_port_text,omitempty"` Network string `json:"network"` + AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"` + AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"` Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -162,6 +164,8 @@ type XInnerConfig struct { Dest string `json:"dest,omitempty"` ServerName string `json:"server_name,omitempty"` Network string `json:"network"` + AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"` + AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"` Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -212,6 +216,67 @@ type GrpcNetworkConfig struct { ServiceName string `json:"serviceName"` } +func unmarshalNetworkSettings(settings json.RawMessage) map[string]any { + if len(settings) == 0 { + return nil + } + var raw map[string]any + if err := json.Unmarshal(settings, &raw); err != nil { + return nil + } + return raw +} + +func readNetworkString(raw map[string]any, keys ...string) string { + for _, key := range keys { + value, exists := raw[key] + if !exists { + continue + } + if stringValue, ok := value.(string); ok && stringValue != "" { + return stringValue + } + } + return "" +} + +func readNetworkBool(raw map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + value, exists := raw[key] + if !exists { + continue + } + if boolValue, ok := value.(bool); ok { + return boolValue, true + } + } + return false, false +} + +func readNetworkDuration(raw map[string]any, keys ...string) (badoption.Duration, bool) { + for _, key := range keys { + value, exists := raw[key] + if !exists { + continue + } + switch typedValue := value.(type) { + case float64: + return badoption.Duration(time.Duration(typedValue) * time.Second), true + case string: + if typedValue == "" { + continue + } + if durationValue, err := time.ParseDuration(typedValue); err == nil { + return badoption.Duration(durationValue), true + } + if secondsValue, err := strconv.ParseInt(typedValue, 10, 64); err == nil { + return badoption.Duration(time.Duration(secondsValue) * time.Second), true + } + } + } + return 0, false +} + type HttpupgradeNetworkConfig struct { Path string `json:"path"` Host string `json:"host"` @@ -466,6 +531,7 @@ func getInboundTransport(network string, settings json.RawMessage) (*option.V2Ra t := &option.V2RayTransportOptions{ Type: network, } + rawSettings := unmarshalNetworkSettings(settings) switch network { case "tcp": if len(settings) != 0 { @@ -535,6 +601,18 @@ func getInboundTransport(network string, settings json.RawMessage) (*option.V2Ra t.GRPCOptions = option.V2RayGRPCOptions{ ServiceName: networkConfig.ServiceName, } + if serviceName := readNetworkString(rawSettings, "service_name"); serviceName != "" && t.GRPCOptions.ServiceName == "" { + t.GRPCOptions.ServiceName = serviceName + } + if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok { + t.GRPCOptions.IdleTimeout = idleTimeout + } + if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok { + t.GRPCOptions.PingTimeout = pingTimeout + } + if permitWithoutStream, ok := readNetworkBool(rawSettings, "permit_without_stream", "permitWithoutStream"); ok { + t.GRPCOptions.PermitWithoutStream = permitWithoutStream + } } case "httpupgrade": if len(settings) != 0 { @@ -548,10 +626,106 @@ func getInboundTransport(network string, settings json.RawMessage) (*option.V2Ra Host: networkConfig.Host, } } + case "h2", "http": + t.Type = "http" + if rawSettings != nil { + t.HTTPOptions = option.V2RayHTTPOptions{ + Path: readNetworkString(rawSettings, "path"), + Method: readNetworkString(rawSettings, "method"), + Headers: nil, + } + if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok { + t.HTTPOptions.IdleTimeout = idleTimeout + } + if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok { + t.HTTPOptions.PingTimeout = pingTimeout + } + if hostValue, exists := rawSettings["host"]; exists { + switch typedHost := hostValue.(type) { + case string: + if typedHost != "" { + t.HTTPOptions.Host = badoption.Listable[string]{typedHost} + } + case []any: + hostList := make(badoption.Listable[string], 0, len(typedHost)) + for _, item := range typedHost { + if host, ok := item.(string); ok && host != "" { + hostList = append(hostList, host) + } + } + if len(hostList) > 0 { + t.HTTPOptions.Host = hostList + } + } + } + if headersValue, exists := rawSettings["headers"]; exists { + if headersMap, ok := headersValue.(map[string]any); ok { + headerOptions := make(badoption.HTTPHeader) + for headerKey, headerValue := range headersMap { + switch typedHeader := headerValue.(type) { + case string: + if typedHeader != "" { + headerOptions[headerKey] = badoption.Listable[string]{typedHeader} + } + case []any: + values := make(badoption.Listable[string], 0, len(typedHeader)) + for _, item := range typedHeader { + if headerString, ok := item.(string); ok && headerString != "" { + values = append(values, headerString) + } + } + if len(values) > 0 { + headerOptions[headerKey] = values + } + } + } + if len(headerOptions) > 0 { + t.HTTPOptions.Headers = headerOptions + } + } + } + } } return t, nil } +func networkAcceptProxyProtocol(settings json.RawMessage) bool { + if len(settings) == 0 { + return false + } + var raw map[string]any + if err := json.Unmarshal(settings, &raw); err != nil { + return false + } + for _, key := range []string{"acceptProxyProtocol", "accept_proxy_protocol"} { + value, exists := raw[key] + if !exists { + continue + } + enabled, ok := value.(bool) + if ok && enabled { + return true + } + } + return false +} + +func acceptProxyProtocolEnabled(inner XInnerConfig, config *XNodeConfig) bool { + if inner.AcceptProxyProtocol || inner.AcceptProxyProtocol_ { + return true + } + if config != nil && (config.AcceptProxyProtocol || config.AcceptProxyProtocol_) { + return true + } + if networkAcceptProxyProtocol(inner.NetworkSettings) || networkAcceptProxyProtocol(inner.NetworkSettings_) { + return true + } + if config != nil && (networkAcceptProxyProtocol(config.NetworkSettings) || networkAcceptProxyProtocol(config.NetworkSettings_)) { + return true + } + return false +} + func (s *Service) setupNode() error { s.logger.Info("Xboard fetching node config...") config, err := s.fetchConfig() @@ -675,6 +849,10 @@ func (s *Service) setupNode() error { Listen: &listenAddr, ListenPort: uint16(inner.Port), } + if acceptProxyProtocolEnabled(inner, config) { + listen.ProxyProtocol = true + s.logger.Info("Xboard PROXY protocol enabled for inbound on ", inner.ListenIP, ":", inner.Port) + } // ── TLS / Reality handling (matching V2bX panel.Security constants) ── // V2bX: 0=None, 1=TLS, 2=Reality @@ -1264,6 +1442,11 @@ func (s *Service) syncUsers() { } } + if sameUserSet(s.localUsers, newUsers) { + s.logger.Trace("Xboard sync skipped: users unchanged") + return + } + for tag, server := range s.servers { // Update users in each manager users := make([]string, 0, len(newUsers)) @@ -1286,6 +1469,22 @@ func (s *Service) syncUsers() { s.logger.Info("Xboard sync completed, total users: ", len(users)) } +func sameUserSet(current map[string]userData, next map[string]userData) bool { + if len(current) != len(next) { + return false + } + for userName, currentUser := range current { + nextUser, exists := next[userName] + if !exists { + return false + } + if currentUser != nextUser { + return false + } + } + return true +} + func (s *Service) reportTraffic() { s.logger.Trace("Xboard reporting traffic...") From 98f39742600703bb5ea45d51386b1e3e1833f3ed Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 16:21:17 +0800 Subject: [PATCH 77/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 35 ++++++++++------ service/xboard/service.go | 88 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/install.sh b/install.sh index f8dacda2..a741d9ce 100644 --- a/install.sh +++ b/install.sh @@ -13,7 +13,10 @@ NC='\033[0m' # Configuration CONFIG_DIR="/etc/sing-box" -CONFIG_FILE="$CONFIG_DIR/config.json" +CONFIG_MERGE_DIR="$CONFIG_DIR/config.d" +CONFIG_BASE_FILE="$CONFIG_MERGE_DIR/10-base.json" +CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/20-outbounds.json" +WORK_DIR="/var/lib/sing-box" BINARY_PATH="/usr/local/bin/sing-box" SERVICE_NAME="ganclient" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" @@ -38,7 +41,8 @@ esac # Prepare directories mkdir -p "$CONFIG_DIR" -mkdir -p "/var/lib/sing-box" +mkdir -p "$CONFIG_MERGE_DIR" +mkdir -p "$WORK_DIR" # Check and Install Go install_go() { @@ -241,7 +245,7 @@ SERVICE_JSON+=$'\n }' # Generate Configuration echo -e "${YELLOW}Generating configuration...${NC}" -cat > "$CONFIG_FILE" < "$CONFIG_BASE_FILE" < "$CONFIG_FILE" < "$CONFIG_FILE" < "$CONFIG_OUTBOUNDS_FILE" < Date: Wed, 15 Apr 2026 16:24:16 +0800 Subject: [PATCH 78/97] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E7=9A=84=E5=9F=BA=E6=9C=AC=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/install.sh b/install.sh index a741d9ce..99f26389 100644 --- a/install.sh +++ b/install.sh @@ -152,18 +152,11 @@ PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}} -read -p "Enter Node Count [${NODE_COUNT:-1}]: " INPUT_COUNT -NODE_COUNT=${INPUT_COUNT:-${NODE_COUNT:-1}} - -if ! [[ "$NODE_COUNT" =~ ^[0-9]+$ ]] || [[ "$NODE_COUNT" -lt 1 ]]; then - echo -e "${RED}Node Count must be a positive integer${NC}" - exit 1 -fi - declare -a NODE_IDS declare -a NODE_TAGS -for ((i=1; i<=NODE_COUNT; i++)); do +i=1 +while true; do DEFAULT_NODE_ID="" DEFAULT_NODE_TAG="" if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then @@ -179,8 +172,16 @@ for ((i=1; i<=NODE_COUNT; i++)); do CURRENT_NODE_TAG=${INPUT_TAG:-$DEFAULT_NODE_TAG} NODE_IDS+=("$CURRENT_NODE_ID") NODE_TAGS+=("$CURRENT_NODE_TAG") + + read -p "Add another node? [y/N]: " INPUT_ADD_ANOTHER + if [[ ! "$INPUT_ADD_ANOTHER" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then + break + fi + ((i++)) done +NODE_COUNT=${#NODE_IDS[@]} + # Sync time (Critical for SS 2022) echo -e "${YELLOW}Syncing system time...${NC}" timedatectl set-ntp true || true From be98cd7628e4df57aea08812be515e23cde9e096 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 16:40:13 +0800 Subject: [PATCH 79/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=AE=89=E8=A3=85=E5=90=8E=E5=BE=88=E5=A4=9A?= =?UTF-8?q?=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E5=9E=83=E5=9C=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 24 ++-------------- service/xboard/service_test.go | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/install.sh b/install.sh index 99f26389..aafcbefa 100644 --- a/install.sh +++ b/install.sh @@ -153,12 +153,10 @@ read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}} declare -a NODE_IDS -declare -a NODE_TAGS i=1 while true; do DEFAULT_NODE_ID="" - DEFAULT_NODE_TAG="" if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then DEFAULT_NODE_ID="$NODE_ID" fi @@ -168,10 +166,7 @@ while true; do echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi - read -p "Enter Tag for node #$i (optional) [${DEFAULT_NODE_TAG}]: " INPUT_TAG - CURRENT_NODE_TAG=${INPUT_TAG:-$DEFAULT_NODE_TAG} NODE_IDS+=("$CURRENT_NODE_ID") - NODE_TAGS+=("$CURRENT_NODE_TAG") read -p "Add another node? [y/N]: " INPUT_ADD_ANOTHER if [[ ! "$INPUT_ADD_ANOTHER" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then @@ -193,15 +188,11 @@ fi # Clean up trailing slash PANEL_URL="${PANEL_URL%/}" -CONFIG_PANEL_URL=$PANEL_URL -USER_PANEL_URL=$PANEL_URL SERVICE_JSON=$(cat < Date: Wed, 15 Apr 2026 16:46:37 +0800 Subject: [PATCH 80/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=8C=E6=B7=BB=E5=8A=A0DNS=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 74 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/install.sh b/install.sh index aafcbefa..5a08a386 100644 --- a/install.sh +++ b/install.sh @@ -160,23 +160,78 @@ while true; do if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then DEFAULT_NODE_ID="$NODE_ID" fi - read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}]: " INPUT_ID + if [[ -n "$DEFAULT_NODE_ID" ]]; then + read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type NO to finish): " INPUT_ID + else + read -p "Enter Node ID for node #$i (type NO to finish): " INPUT_ID + fi CURRENT_NODE_ID=${INPUT_ID:-$DEFAULT_NODE_ID} + if [[ "$CURRENT_NODE_ID" =~ ^([nN][oO])$ ]]; then + if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then + echo -e "${RED}At least one Node ID is required${NC}" + exit 1 + fi + break + fi if [[ -z "$CURRENT_NODE_ID" ]]; then echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi - NODE_IDS+=("$CURRENT_NODE_ID") - - read -p "Add another node? [y/N]: " INPUT_ADD_ANOTHER - if [[ ! "$INPUT_ADD_ANOTHER" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then - break + if ! [[ "$CURRENT_NODE_ID" =~ ^[0-9]+$ ]]; then + echo -e "${RED}Node ID must be a positive integer${NC}" + exit 1 fi + NODE_IDS+=("$CURRENT_NODE_ID") ((i++)) done NODE_COUNT=${#NODE_IDS[@]} +DNS_MODE_DEFAULT=${DNS_MODE:-udp} +read -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE +DNS_MODE=$(echo "${INPUT_DNS_MODE:-$DNS_MODE_DEFAULT}" | tr '[:upper:]' '[:lower:]') + +case "$DNS_MODE" in + udp) + DNS_SERVER_DEFAULT=${DNS_SERVER:-1.1.1.1} + DNS_SERVER_PORT_DEFAULT=${DNS_SERVER_PORT:-53} + read -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER + DNS_SERVER=${INPUT_DNS_SERVER:-$DNS_SERVER_DEFAULT} + read -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT + DNS_SERVER_PORT=${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT} + if [[ -z "$DNS_SERVER" ]]; then + echo -e "${RED}DNS server is required in udp mode${NC}" + exit 1 + fi + if ! [[ "$DNS_SERVER_PORT" =~ ^[0-9]+$ ]] || [[ "$DNS_SERVER_PORT" -lt 1 ]] || [[ "$DNS_SERVER_PORT" -gt 65535 ]]; then + echo -e "${RED}DNS server port must be an integer between 1 and 65535${NC}" + exit 1 + fi + DNS_SERVER_JSON=$(cat < "$CONFIG_BASE_FILE" < Date: Wed, 15 Apr 2026 18:01:40 +0800 Subject: [PATCH 81/97] =?UTF-8?q?=E4=BC=A0=E8=BE=93=E5=B1=82=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 5 +---- box.go | 36 ++---------------------------------- route/route.go | 6 ++---- service/resolved/resolve1.go | 2 +- 4 files changed, 6 insertions(+), 43 deletions(-) diff --git a/Makefile b/Makefile index 1a1138cc..d1d0e00f 100644 --- a/Makefile +++ b/Makefile @@ -106,13 +106,10 @@ release_android: lib_android update_android_version build_android upload_android publish_android: cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop -# TODO: find why and remove `-destination 'generic/platform=iOS'` -# TODO: remove xcode clean when fix control widget fixed build_ios: cd ../sing-box-for-apple && \ rm -rf build/SFI.xcarchive && \ - xcodebuild clean -scheme SFI && \ - xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌" + xcodebuild archive -scheme SFI -configuration Release -archivePath build/SFI.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌" upload_ios_app_store: cd ../sing-box-for-apple && \ diff --git a/box.go b/box.go index b4844f9e..91e9fb9b 100644 --- a/box.go +++ b/box.go @@ -250,15 +250,8 @@ func New(options Options) (*Box, error) { } else { tag = F.ToString(i) } - endpointCtx := ctx - if tag != "" { - // TODO: remove this - endpointCtx = adapter.WithContext(endpointCtx, &adapter.InboundContext{ - Outbound: tag, - }) - } err = endpointManager.Create( - endpointCtx, + ctx, router, logFactory.NewLogger(F.ToString("endpoint/", endpointOptions.Type, "[", tag, "]")), tag, @@ -313,15 +306,8 @@ func New(options Options) (*Box, error) { } else { tag = F.ToString(i) } - outboundCtx := ctx - if tag != "" { - // TODO: remove this - outboundCtx = adapter.WithContext(outboundCtx, &adapter.InboundContext{ - Outbound: tag, - }) - } err = outboundManager.Create( - outboundCtx, + ctx, router, logFactory.NewLogger(F.ToString("outbound/", outboundOptions.Type, "[", tag, "]")), tag, @@ -439,15 +425,6 @@ func New(options Options) (*Box, error) { func (s *Box) PreStart() error { err := s.preStart() if err != nil { - // TODO: remove catch error - defer func() { - v := recover() - if v != nil { - println(err.Error()) - debug.PrintStack() - panic("panic on early close: " + fmt.Sprint(v)) - } - }() s.Close() return err } @@ -458,15 +435,6 @@ func (s *Box) PreStart() error { func (s *Box) Start() error { err := s.start() if err != nil { - // TODO: remove catch error - defer func() { - v := recover() - if v != nil { - println(err.Error()) - debug.PrintStack() - println("panic on early start: " + fmt.Sprint(v)) - } - }() s.Close() return err } diff --git a/route/route.go b/route/route.go index 32e07bae..f9188b22 100644 --- a/route/route.go +++ b/route/route.go @@ -715,8 +715,7 @@ func (r *Router) actionSniff( metadata.SnifferNames = action.SnifferNames metadata.SniffError = err if errors.Is(err, sniff.ErrNeedMoreData) { - // TODO: replace with generic message when there are more multi-packet protocols - r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello") + r.logger.DebugContext(ctx, "attempt to sniff fragmented multi-packet protocol") continue } goto finally @@ -775,8 +774,7 @@ func (r *Router) actionSniff( metadata.SnifferNames = action.SnifferNames metadata.SniffError = err if errors.Is(err, sniff.ErrNeedMoreData) { - // TODO: replace with generic message when there are more multi-packet protocols - r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello") + r.logger.DebugContext(ctx, "attempt to sniff fragmented multi-packet protocol") continue } } diff --git a/service/resolved/resolve1.go b/service/resolved/resolve1.go index ed1ee41a..77da3bf8 100644 --- a/service/resolved/resolve1.go +++ b/service/resolved/resolve1.go @@ -609,7 +609,7 @@ func (t *resolve1Manager) RevertLink(sender dbus.Sender, ifIndex int32) *dbus.Er return t.postUpdate(link) } -// TODO: implement RegisterService, UnregisterService + func (t *resolve1Manager) RegisterService(sender dbus.Sender, identifier string, nameTemplate string, serviceType string, port uint16, priority uint16, weight uint16, txtRecords []TXTRecord) (objectPath dbus.ObjectPath, dbusErr *dbus.Error) { return "", wrapError(E.New("not implemented")) From dfdf9a8ed72a13c1b7ecba035a18d30351e7653d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 18:06:58 +0800 Subject: [PATCH 82/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E8=BF=99?= =?UTF-8?q?=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- box.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/box.go b/box.go index 91e9fb9b..c64f1f8b 100644 --- a/box.go +++ b/box.go @@ -2,10 +2,8 @@ package box import ( "context" - "fmt" "io" "os" - "runtime/debug" "time" "github.com/sagernet/sing-box/adapter" From abc7c0d93359fe54a9eac3506881103f4330a357 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 18:42:45 +0800 Subject: [PATCH 83/97] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=AD=BE=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 2 +- service/xboard/service.go | 145 ++++++++++++++++++++++++++++++++- service/xboard/service_test.go | 67 +++++++++++++++ 3 files changed, 209 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 5a08a386..11b9a199 100644 --- a/install.sh +++ b/install.sh @@ -89,7 +89,7 @@ build_sing_box() { # Build params from Makefile VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") # Reduced tags for safer build on smaller servers - TAGS="with_quic,with_utls,with_clash_api,with_gvisor" + TAGS="with_quic,with_utls,with_clash_api,with_gvisor,with_acme" echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" echo -e "${YELLOW}Note: Using -p 1 to save memory and avoid silent crashes.${NC}" diff --git a/service/xboard/service.go b/service/xboard/service.go index 149b2684..40df7cd0 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -184,6 +184,9 @@ type XInnerConfig struct { StreamSettings json.RawMessage `json:"streamSettings"` UpMbps int `json:"up_mbps"` DownMbps int `json:"down_mbps"` + CertConfig *XCertConfig `json:"cert_config,omitempty"` + AutoTLS bool `json:"auto_tls,omitempty"` + Domain string `json:"domain,omitempty"` } type XMultiplexConfig struct { @@ -438,6 +441,94 @@ func applyCertConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConf } } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mergedCertConfig(inner XInnerConfig, config *XNodeConfig) *XCertConfig { + if inner.CertConfig != nil { + return inner.CertConfig + } + if config != nil { + return config.CertConfig + } + return nil +} + +func hasUsableServerTLS(tlsOptions option.InboundTLSOptions) bool { + return tlsOptions.CertificateProvider != nil || + tlsOptions.ACME != nil && len(tlsOptions.ACME.Domain) > 0 || + (tlsOptions.CertificatePath != "" && tlsOptions.KeyPath != "") || + (len(tlsOptions.Certificate) > 0 && len(tlsOptions.Key) > 0) +} + +func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) bool { + if !autoTLS && certConfig == nil { + return false + } + mode := "" + if certConfig != nil { + mode = strings.ToLower(strings.TrimSpace(certConfig.CertMode)) + } + if mode == "" && autoTLS { + mode = "http" + } + if mode != "http" && mode != "dns" { + return false + } + domain = strings.TrimSpace(domain) + if domain == "" { + return false + } + + acmeOptions := &option.InboundACMEOptions{ + Domain: badoption.Listable[string]{domain}, + DataDirectory: "acme", + DefaultServerName: domain, + DisableHTTPChallenge: true, + } + if certConfig != nil { + acmeOptions.Email = strings.TrimSpace(certConfig.Email) + } + if listenPort > 0 && listenPort != 443 { + acmeOptions.AlternativeTLSPort = uint16(listenPort) + } + if mode == "dns" && certConfig != nil { + dnsProvider := strings.ToLower(strings.TrimSpace(certConfig.DNSProvider)) + if dnsProvider == "" { + return false + } + acmeOptions.DisableHTTPChallenge = true + acmeOptions.DisableTLSALPNChallenge = true + dns01 := &option.ACMEDNS01ChallengeOptions{Provider: dnsProvider} + switch dnsProvider { + case C.DNSProviderCloudflare: + dns01.CloudflareOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["CF_API_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_API_TOKEN"]) + dns01.CloudflareOptions.ZoneToken = firstNonEmpty(certConfig.DNSEnv["CF_ZONE_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_ZONE_TOKEN"]) + case C.DNSProviderAliDNS: + dns01.AliDNSOptions.AccessKeyID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_ID"], certConfig.DNSEnv["ALI_ACCESS_KEY_ID"]) + dns01.AliDNSOptions.AccessKeySecret = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_SECRET"], certConfig.DNSEnv["ALI_ACCESS_KEY_SECRET"]) + dns01.AliDNSOptions.RegionID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_REGION_ID"], certConfig.DNSEnv["ALI_REGION_ID"]) + dns01.AliDNSOptions.SecurityToken = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_SECURITY_TOKEN"], certConfig.DNSEnv["ALI_SECURITY_TOKEN"]) + case C.DNSProviderACMEDNS: + dns01.ACMEDNSOptions.Username = certConfig.DNSEnv["ACMEDNS_USERNAME"] + dns01.ACMEDNSOptions.Password = certConfig.DNSEnv["ACMEDNS_PASSWORD"] + dns01.ACMEDNSOptions.Subdomain = certConfig.DNSEnv["ACMEDNS_SUBDOMAIN"] + dns01.ACMEDNSOptions.ServerURL = certConfig.DNSEnv["ACMEDNS_SERVER_URL"] + default: + return false + } + acmeOptions.DNS01Challenge = dns01 + } + tlsOptions.ACME = acmeOptions + return true +} + func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings { tlsSettings := inner.TLSSettings if tlsSettings == nil { @@ -880,6 +971,15 @@ func (s *Service) setupNode() error { if len(inner.NetworkSettings_) == 0 { inner.NetworkSettings_ = config.NetworkSettings_ } + if inner.CertConfig == nil { + inner.CertConfig = config.CertConfig + } + if !inner.AutoTLS { + inner.AutoTLS = config.AutoTLS + } + if inner.Domain == "" { + inner.Domain = config.Domain + } // Resolve protocol protocol := inner.Protocol @@ -950,9 +1050,31 @@ func (s *Service) setupNode() error { if tlsSettings != nil && tlsSettings.ServerName != "" { s.vlessServerName = tlsSettings.ServerName } - hasCertificate := applyCertConfig(&tlsOptions, config.CertConfig) - if config.CertConfig != nil && !hasCertificate && config.CertConfig.CertMode != "" && config.CertConfig.CertMode != "none" { - s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", config.CertConfig.CertMode) + certConfig := mergedCertConfig(inner, config) + hasCertificate := applyCertConfig(&tlsOptions, certConfig) + configDomain := "" + configAutoTLS := false + if config != nil { + configDomain = config.Domain + configAutoTLS = config.AutoTLS + } + certDomain := "" + if certConfig != nil { + certDomain = certConfig.Domain + } + autoTLSDomain := firstNonEmpty(inner.Domain, configDomain, certDomain) + if autoTLSDomain == "" && tlsSettings != nil { + autoTLSDomain = tlsSettings.ServerName + } + hasACME := false + if !hasCertificate { + hasACME = applyACMEConfig(&tlsOptions, certConfig, inner.AutoTLS || configAutoTLS, autoTLSDomain, inner.Port) + } + if certConfig != nil && !hasCertificate && !hasACME && certConfig.CertMode != "" && certConfig.CertMode != "none" { + s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", certConfig.CertMode) + } + if hasACME { + s.logger.Info("Xboard ACME configured for domain ", autoTLSDomain) } switch securityType { @@ -1043,7 +1165,7 @@ func (s *Service) setupNode() error { } } - if securityType == 1 && !tlsOptions.Enabled { + if securityType == 1 && !tlsOptions.Enabled && !hasUsableServerTLS(tlsOptions) { s.logger.Warn("Xboard TLS enabled by panel but no usable certificate material found; inbound will run without local TLS") } @@ -1163,6 +1285,9 @@ func (s *Service) setupNode() error { inboundOptions = ssOptions case "trojan": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("trojan requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // Trojan supports ws/grpc transport like V2bX transport, err := getInboundTransport(networkType, networkSettings) if err != nil { @@ -1181,6 +1306,9 @@ func (s *Service) setupNode() error { } inboundOptions = opts case "tuic": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("tuic requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: TUIC always uses TLS with h3 ALPN tuicTLS := tlsOptions tuicTLS.Enabled = true @@ -1202,6 +1330,9 @@ func (s *Service) setupNode() error { inboundOptions = opts s.logger.Info("Xboard TUIC configured. CongestionControl: ", congestionControl) case "hysteria": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("hysteria requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: Hysteria always uses TLS hyTLS := tlsOptions hyTLS.Enabled = true @@ -1218,6 +1349,9 @@ func (s *Service) setupNode() error { inboundOptions = opts s.logger.Info("Xboard Hysteria configured. Up: ", config.UpMbps, " Down: ", config.DownMbps) case "hysteria2": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("hysteria2 requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: Hysteria2 always uses TLS, optional obfs hy2TLS := tlsOptions hy2TLS.Enabled = true @@ -1249,6 +1383,9 @@ func (s *Service) setupNode() error { inboundOptions = opts s.logger.Info("Xboard Hysteria2 configured. Up: ", config.UpMbps, " Down: ", config.DownMbps, " IgnoreClientBW: ", config.Ignore_Client_Bandwidth) case "anytls": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("anytls requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: AnyTLS always uses TLS anyTLS := tlsOptions anyTLS.Enabled = true diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index 3372d320..9ae25d48 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" ) func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) { @@ -145,6 +146,72 @@ func TestExpandedNodeTagFallsBackToNodeID(t *testing.T) { } } +func TestApplyACMEConfigFromAutoTLS(t *testing.T) { + var tlsOptions option.InboundTLSOptions + ok := applyACMEConfig(&tlsOptions, nil, true, "example.com", 8443) + if !ok { + t.Fatal("applyACMEConfig() returned false") + } + if tlsOptions.ACME == nil { + t.Fatal("ACME options not configured") + } + if len(tlsOptions.ACME.Domain) != 1 || tlsOptions.ACME.Domain[0] != "example.com" { + t.Fatalf("ACME domains = %+v", tlsOptions.ACME.Domain) + } + if tlsOptions.ACME.AlternativeTLSPort != 8443 { + t.Fatalf("AlternativeTLSPort = %d, want 8443", tlsOptions.ACME.AlternativeTLSPort) + } + if !tlsOptions.ACME.DisableHTTPChallenge { + t.Fatal("DisableHTTPChallenge should be true for inline ACME") + } +} + +func TestApplyACMEConfigFromDNSCertMode(t *testing.T) { + var tlsOptions option.InboundTLSOptions + ok := applyACMEConfig(&tlsOptions, &XCertConfig{ + CertMode: "dns", + Domain: "example.com", + DNSProvider: "cloudflare", + DNSEnv: map[string]string{ + "CF_API_TOKEN": "token", + }, + }, false, "example.com", 443) + if !ok { + t.Fatal("applyACMEConfig() returned false") + } + if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil { + t.Fatal("DNS01Challenge not configured") + } + if tlsOptions.ACME.DNS01Challenge.Provider != "cloudflare" { + t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider) + } + if tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken != "token" { + t.Fatalf("Cloudflare API token = %q", tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken) + } + if !tlsOptions.ACME.DisableTLSALPNChallenge { + t.Fatal("DisableTLSALPNChallenge should be true for dns mode") + } +} + +func TestHasUsableServerTLS(t *testing.T) { + if hasUsableServerTLS(option.InboundTLSOptions{}) { + t.Fatal("empty TLS options should not be usable") + } + if !hasUsableServerTLS(option.InboundTLSOptions{ + CertificatePath: "cert.pem", + KeyPath: "key.pem", + }) { + t.Fatal("file certificate should be usable") + } + if !hasUsableServerTLS(option.InboundTLSOptions{ + ACME: &option.InboundACMEOptions{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }) { + t.Fatal("ACME certificate should be usable") + } +} + func TestBuildInboundMultiplex(t *testing.T) { config := &XMultiplexConfig{ Enabled: true, From a2a3ba12b3335946f0cd39b3639b0e7cc8226d03 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 18:57:33 +0800 Subject: [PATCH 84/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAnyTLS=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E9=85=8D=E7=BD=AE=E8=AF=81=E4=B9=A6=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/tls/acme.go | 15 +++++- constant/dns.go | 23 ++++++++++ go.mod | 2 + option/acme.go | 36 ++++++++++----- option/tls_acme.go | 37 ++++++++++++--- service/acme/service.go | 15 +++++- service/xboard/service.go | 83 ++++++++++++++++++++++++++-------- service/xboard/service_test.go | 74 ++++++++++++++++++++++++++++-- 8 files changed, 243 insertions(+), 42 deletions(-) diff --git a/common/tls/acme.go b/common/tls/acme.go index d576fc6b..02e87e5f 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -17,6 +17,8 @@ import ( "github.com/libdns/acmedns" "github.com/libdns/alidns" "github.com/libdns/cloudflare" + "github.com/libdns/dnspod" + "github.com/libdns/tencentcloud" "github.com/mholt/acmez/v3/acme" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -81,7 +83,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound } if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" { var solver certmagic.DNS01Solver - switch dnsOptions.Provider { + switch C.NormalizeACMEDNSProvider(dnsOptions.Provider) { case C.DNSProviderAliDNS: solver.DNSProvider = &alidns.Provider{ CredentialInfo: alidns.CredentialInfo{ @@ -96,6 +98,17 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound APIToken: dnsOptions.CloudflareOptions.APIToken, ZoneToken: dnsOptions.CloudflareOptions.ZoneToken, } + case C.DNSProviderTencentCloud: + solver.DNSProvider = &tencentcloud.Provider{ + SecretId: dnsOptions.TencentCloudOptions.SecretID, + SecretKey: dnsOptions.TencentCloudOptions.SecretKey, + SessionToken: dnsOptions.TencentCloudOptions.SessionToken, + Region: dnsOptions.TencentCloudOptions.Region, + } + case C.DNSProviderDNSPod: + solver.DNSProvider = &dnspod.Provider{ + APIToken: dnsOptions.DNSPodOptions.APIToken, + } case C.DNSProviderACMEDNS: solver.DNSProvider = &acmedns.Provider{ Username: dnsOptions.ACMEDNSOptions.Username, diff --git a/constant/dns.go b/constant/dns.go index c7cd0d03..d30f56df 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -1,5 +1,7 @@ package constant +import "strings" + const ( DefaultDNSTTL = 600 ) @@ -33,4 +35,25 @@ const ( DNSProviderAliDNS = "alidns" DNSProviderCloudflare = "cloudflare" DNSProviderACMEDNS = "acmedns" + DNSProviderTencentCloud = "tencentcloud" + DNSProviderDNSPod = "dnspod" ) + +func NormalizeACMEDNSProvider(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "", DNSProviderAliDNS, DNSProviderCloudflare, DNSProviderACMEDNS: + return strings.ToLower(strings.TrimSpace(provider)) + case "aliyun": + return DNSProviderAliDNS + case "cf": + return DNSProviderCloudflare + case "acme-dns": + return DNSProviderACMEDNS + case "tencent", "tencentcloud", "dnspod-tencentcloud", "qcloud": + return DNSProviderTencentCloud + case "dnspod": + return DNSProviderDNSPod + default: + return strings.ToLower(strings.TrimSpace(provider)) + } +} diff --git a/go.mod b/go.mod index 4a9c656a..3b54027a 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,9 @@ require ( github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 + github.com/libdns/dnspod v0.0.3 github.com/libdns/libdns v1.1.1 + github.com/libdns/tencentcloud v1.4.3 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mdlayher/netlink v1.9.0 github.com/metacubex/utls v1.8.4 diff --git a/option/acme.go b/option/acme.go index ea9349b7..4eb8638d 100644 --- a/option/acme.go +++ b/option/acme.go @@ -28,34 +28,43 @@ type ACMECertificateProviderOptions struct { } type _ACMEProviderDNS01ChallengeOptions struct { - TTL badoption.Duration `json:"ttl,omitempty"` - PropagationDelay badoption.Duration `json:"propagation_delay,omitempty"` - PropagationTimeout badoption.Duration `json:"propagation_timeout,omitempty"` - Resolvers badoption.Listable[string] `json:"resolvers,omitempty"` - OverrideDomain string `json:"override_domain,omitempty"` - Provider string `json:"provider,omitempty"` - AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` - CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` - ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` + TTL badoption.Duration `json:"ttl,omitempty"` + PropagationDelay badoption.Duration `json:"propagation_delay,omitempty"` + PropagationTimeout badoption.Duration `json:"propagation_timeout,omitempty"` + Resolvers badoption.Listable[string] `json:"resolvers,omitempty"` + OverrideDomain string `json:"override_domain,omitempty"` + Provider string `json:"provider,omitempty"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` + TencentCloudOptions ACMEDNS01TencentCloudOptions `json:"-"` + DNSPodOptions ACMEDNS01DNSPodOptions `json:"-"` } type ACMEProviderDNS01ChallengeOptions _ACMEProviderDNS01ChallengeOptions func (o ACMEProviderDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { + provider := C.NormalizeACMEDNSProvider(o.Provider) var v any - switch o.Provider { + switch provider { case C.DNSProviderAliDNS: v = o.AliDNSOptions case C.DNSProviderCloudflare: v = o.CloudflareOptions case C.DNSProviderACMEDNS: v = o.ACMEDNSOptions + case C.DNSProviderTencentCloud: + v = o.TencentCloudOptions + case C.DNSProviderDNSPod: + v = o.DNSPodOptions case "": return nil, E.New("missing provider type") default: return nil, E.New("unknown provider type: ", o.Provider) } - return badjson.MarshallObjects((_ACMEProviderDNS01ChallengeOptions)(o), v) + copyValue := (_ACMEProviderDNS01ChallengeOptions)(o) + copyValue.Provider = provider + return badjson.MarshallObjects(copyValue, v) } func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { @@ -63,6 +72,7 @@ func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { if err != nil { return err } + o.Provider = C.NormalizeACMEDNSProvider(o.Provider) var v any switch o.Provider { case C.DNSProviderAliDNS: @@ -71,6 +81,10 @@ func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { v = &o.CloudflareOptions case C.DNSProviderACMEDNS: v = &o.ACMEDNSOptions + case C.DNSProviderTencentCloud: + v = &o.TencentCloudOptions + case C.DNSProviderDNSPod: + v = &o.DNSPodOptions case "": return E.New("missing provider type") default: diff --git a/option/tls_acme.go b/option/tls_acme.go index 6dd8fa70..47ab9b19 100644 --- a/option/tls_acme.go +++ b/option/tls_acme.go @@ -28,29 +28,38 @@ type ACMEExternalAccountOptions struct { } type _ACMEDNS01ChallengeOptions struct { - Provider string `json:"provider,omitempty"` - AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` - CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` - ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` + Provider string `json:"provider,omitempty"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` + TencentCloudOptions ACMEDNS01TencentCloudOptions `json:"-"` + DNSPodOptions ACMEDNS01DNSPodOptions `json:"-"` } type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { + provider := C.NormalizeACMEDNSProvider(o.Provider) var v any - switch o.Provider { + switch provider { case C.DNSProviderAliDNS: v = o.AliDNSOptions case C.DNSProviderCloudflare: v = o.CloudflareOptions case C.DNSProviderACMEDNS: v = o.ACMEDNSOptions + case C.DNSProviderTencentCloud: + v = o.TencentCloudOptions + case C.DNSProviderDNSPod: + v = o.DNSPodOptions case "": return nil, E.New("missing provider type") default: return nil, E.New("unknown provider type: " + o.Provider) } - return badjson.MarshallObjects((_ACMEDNS01ChallengeOptions)(o), v) + copyValue := (_ACMEDNS01ChallengeOptions)(o) + copyValue.Provider = provider + return badjson.MarshallObjects(copyValue, v) } func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { @@ -58,6 +67,7 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { if err != nil { return err } + o.Provider = C.NormalizeACMEDNSProvider(o.Provider) var v any switch o.Provider { case C.DNSProviderAliDNS: @@ -66,6 +76,10 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { v = &o.CloudflareOptions case C.DNSProviderACMEDNS: v = &o.ACMEDNSOptions + case C.DNSProviderTencentCloud: + v = &o.TencentCloudOptions + case C.DNSProviderDNSPod: + v = &o.DNSPodOptions default: return E.New("unknown provider type: " + o.Provider) } @@ -94,3 +108,14 @@ type ACMEDNS01ACMEDNSOptions struct { Subdomain string `json:"subdomain,omitempty"` ServerURL string `json:"server_url,omitempty"` } + +type ACMEDNS01TencentCloudOptions struct { + SecretID string `json:"secret_id,omitempty"` + SecretKey string `json:"secret_key,omitempty"` + SessionToken string `json:"session_token,omitempty"` + Region string `json:"region,omitempty"` +} + +type ACMEDNS01DNSPodOptions struct { + APIToken string `json:"api_token,omitempty"` +} diff --git a/service/acme/service.go b/service/acme/service.go index 8286a197..fd346e46 100644 --- a/service/acme/service.go +++ b/service/acme/service.go @@ -30,7 +30,9 @@ import ( "github.com/caddyserver/zerossl" "github.com/libdns/alidns" "github.com/libdns/cloudflare" + "github.com/libdns/dnspod" "github.com/libdns/libdns" + "github.com/libdns/tencentcloud" "github.com/mholt/acmez/v3/acme" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -224,7 +226,7 @@ func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger * Logger: logger.Named("dns_manager"), }, } - switch dnsOptions.Provider { + switch C.NormalizeACMEDNSProvider(dnsOptions.Provider) { case C.DNSProviderAliDNS: solver.DNSProvider = &alidns.Provider{ CredentialInfo: alidns.CredentialInfo{ @@ -240,6 +242,17 @@ func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger * ZoneToken: dnsOptions.CloudflareOptions.ZoneToken, HTTPClient: httpClient, } + case C.DNSProviderTencentCloud: + solver.DNSProvider = &tencentcloud.Provider{ + SecretId: dnsOptions.TencentCloudOptions.SecretID, + SecretKey: dnsOptions.TencentCloudOptions.SecretKey, + SessionToken: dnsOptions.TencentCloudOptions.SessionToken, + Region: dnsOptions.TencentCloudOptions.Region, + } + case C.DNSProviderDNSPod: + solver.DNSProvider = &dnspod.Provider{ + APIToken: dnsOptions.DNSPodOptions.APIToken, + } case C.DNSProviderACMEDNS: solver.DNSProvider = &acmeDNSProvider{ username: dnsOptions.ACMEDNSOptions.Username, diff --git a/service/xboard/service.go b/service/xboard/service.go index 40df7cd0..d2d152ba 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -467,9 +467,9 @@ func hasUsableServerTLS(tlsOptions option.InboundTLSOptions) bool { (len(tlsOptions.Certificate) > 0 && len(tlsOptions.Key) > 0) } -func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) bool { +func applyACMEConfigDetailed(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) (bool, string) { if !autoTLS && certConfig == nil { - return false + return false, "acme disabled: no auto_tls and no cert_config" } mode := "" if certConfig != nil { @@ -479,54 +479,98 @@ func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConf mode = "http" } if mode != "http" && mode != "dns" { - return false + return false, "unsupported cert_mode: " + mode } domain = strings.TrimSpace(domain) if domain == "" { - return false + return false, "missing domain/server_name for ACME" } acmeOptions := &option.InboundACMEOptions{ - Domain: badoption.Listable[string]{domain}, - DataDirectory: "acme", - DefaultServerName: domain, - DisableHTTPChallenge: true, + Domain: badoption.Listable[string]{domain}, + DataDirectory: "acme", + DefaultServerName: domain, } if certConfig != nil { acmeOptions.Email = strings.TrimSpace(certConfig.Email) } - if listenPort > 0 && listenPort != 443 { - acmeOptions.AlternativeTLSPort = uint16(listenPort) - } - if mode == "dns" && certConfig != nil { - dnsProvider := strings.ToLower(strings.TrimSpace(certConfig.DNSProvider)) - if dnsProvider == "" { - return false + switch mode { + case "http": + acmeOptions.DisableHTTPChallenge = false + acmeOptions.DisableTLSALPNChallenge = true + if certConfig != nil && certConfig.HTTPPort > 0 && certConfig.HTTPPort != 80 { + acmeOptions.AlternativeHTTPPort = uint16(certConfig.HTTPPort) } + case "dns": acmeOptions.DisableHTTPChallenge = true acmeOptions.DisableTLSALPNChallenge = true + if listenPort > 0 && listenPort != 443 { + acmeOptions.AlternativeTLSPort = uint16(listenPort) + } + } + if mode == "dns" && certConfig != nil { + dnsProvider := C.NormalizeACMEDNSProvider(certConfig.DNSProvider) + if dnsProvider == "" { + return false, "missing dns_provider for cert_mode=dns" + } dns01 := &option.ACMEDNS01ChallengeOptions{Provider: dnsProvider} switch dnsProvider { case C.DNSProviderCloudflare: dns01.CloudflareOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["CF_API_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_API_TOKEN"]) dns01.CloudflareOptions.ZoneToken = firstNonEmpty(certConfig.DNSEnv["CF_ZONE_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_ZONE_TOKEN"]) + if dns01.CloudflareOptions.APIToken == "" && dns01.CloudflareOptions.ZoneToken == "" { + return false, "cloudflare dns challenge requires CF_API_TOKEN/CLOUDFLARE_API_TOKEN or CF_ZONE_TOKEN/CLOUDFLARE_ZONE_TOKEN" + } case C.DNSProviderAliDNS: dns01.AliDNSOptions.AccessKeyID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_ID"], certConfig.DNSEnv["ALI_ACCESS_KEY_ID"]) dns01.AliDNSOptions.AccessKeySecret = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_SECRET"], certConfig.DNSEnv["ALI_ACCESS_KEY_SECRET"]) dns01.AliDNSOptions.RegionID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_REGION_ID"], certConfig.DNSEnv["ALI_REGION_ID"]) dns01.AliDNSOptions.SecurityToken = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_SECURITY_TOKEN"], certConfig.DNSEnv["ALI_SECURITY_TOKEN"]) + if dns01.AliDNSOptions.AccessKeyID == "" || dns01.AliDNSOptions.AccessKeySecret == "" { + return false, "alidns dns challenge requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET" + } + case C.DNSProviderTencentCloud: + dns01.TencentCloudOptions.SecretID = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_ID"], certConfig.DNSEnv["TENCENT_SECRET_ID"], certConfig.DNSEnv["SECRET_ID"]) + dns01.TencentCloudOptions.SecretKey = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_KEY"], certConfig.DNSEnv["TENCENT_SECRET_KEY"], certConfig.DNSEnv["SECRET_KEY"]) + dns01.TencentCloudOptions.SessionToken = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SESSION_TOKEN"], certConfig.DNSEnv["TENCENT_SESSION_TOKEN"], certConfig.DNSEnv["SESSION_TOKEN"]) + dns01.TencentCloudOptions.Region = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_REGION"], certConfig.DNSEnv["TENCENT_REGION"], certConfig.DNSEnv["REGION"]) + if dns01.TencentCloudOptions.SecretID == "" || dns01.TencentCloudOptions.SecretKey == "" { + return false, "tencentcloud dns challenge requires TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY" + } + case C.DNSProviderDNSPod: + dns01.DNSPodOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["DNSPOD_TOKEN"], certConfig.DNSEnv["API_TOKEN"]) + if dns01.DNSPodOptions.APIToken == "" { + tencentSecretID := firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_ID"], certConfig.DNSEnv["TENCENT_SECRET_ID"], certConfig.DNSEnv["SECRET_ID"]) + tencentSecretKey := firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_KEY"], certConfig.DNSEnv["TENCENT_SECRET_KEY"], certConfig.DNSEnv["SECRET_KEY"]) + if tencentSecretID == "" || tencentSecretKey == "" { + return false, "dnspod dns challenge requires DNSPOD_TOKEN or TencentCloud SecretID/SecretKey" + } + dns01.Provider = C.DNSProviderTencentCloud + dns01.TencentCloudOptions.SecretID = tencentSecretID + dns01.TencentCloudOptions.SecretKey = tencentSecretKey + dns01.TencentCloudOptions.SessionToken = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SESSION_TOKEN"], certConfig.DNSEnv["TENCENT_SESSION_TOKEN"], certConfig.DNSEnv["SESSION_TOKEN"]) + dns01.TencentCloudOptions.Region = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_REGION"], certConfig.DNSEnv["TENCENT_REGION"], certConfig.DNSEnv["REGION"]) + } case C.DNSProviderACMEDNS: dns01.ACMEDNSOptions.Username = certConfig.DNSEnv["ACMEDNS_USERNAME"] dns01.ACMEDNSOptions.Password = certConfig.DNSEnv["ACMEDNS_PASSWORD"] dns01.ACMEDNSOptions.Subdomain = certConfig.DNSEnv["ACMEDNS_SUBDOMAIN"] dns01.ACMEDNSOptions.ServerURL = certConfig.DNSEnv["ACMEDNS_SERVER_URL"] + if dns01.ACMEDNSOptions.Username == "" || dns01.ACMEDNSOptions.Password == "" || dns01.ACMEDNSOptions.Subdomain == "" || dns01.ACMEDNSOptions.ServerURL == "" { + return false, "acmedns dns challenge requires username, password, subdomain and server_url" + } default: - return false + return false, "unsupported dns_provider: " + dnsProvider } acmeOptions.DNS01Challenge = dns01 } tlsOptions.ACME = acmeOptions - return true + return true, "" +} + +func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) bool { + ok, _ := applyACMEConfigDetailed(tlsOptions, certConfig, autoTLS, domain, listenPort) + return ok } func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings { @@ -1067,11 +1111,12 @@ func (s *Service) setupNode() error { autoTLSDomain = tlsSettings.ServerName } hasACME := false + acmeReason := "" if !hasCertificate { - hasACME = applyACMEConfig(&tlsOptions, certConfig, inner.AutoTLS || configAutoTLS, autoTLSDomain, inner.Port) + hasACME, acmeReason = applyACMEConfigDetailed(&tlsOptions, certConfig, inner.AutoTLS || configAutoTLS, autoTLSDomain, inner.Port) } if certConfig != nil && !hasCertificate && !hasACME && certConfig.CertMode != "" && certConfig.CertMode != "none" { - s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", certConfig.CertMode) + s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", certConfig.CertMode, ", reason=", acmeReason) } if hasACME { s.logger.Info("Xboard ACME configured for domain ", autoTLSDomain) diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index 9ae25d48..3ec11d67 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -3,6 +3,7 @@ package xboard import ( "testing" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/json/badoption" ) @@ -158,11 +159,11 @@ func TestApplyACMEConfigFromAutoTLS(t *testing.T) { if len(tlsOptions.ACME.Domain) != 1 || tlsOptions.ACME.Domain[0] != "example.com" { t.Fatalf("ACME domains = %+v", tlsOptions.ACME.Domain) } - if tlsOptions.ACME.AlternativeTLSPort != 8443 { - t.Fatalf("AlternativeTLSPort = %d, want 8443", tlsOptions.ACME.AlternativeTLSPort) + if tlsOptions.ACME.DisableHTTPChallenge { + t.Fatal("DisableHTTPChallenge should be false for auto_tls/http mode") } - if !tlsOptions.ACME.DisableHTTPChallenge { - t.Fatal("DisableHTTPChallenge should be true for inline ACME") + if !tlsOptions.ACME.DisableTLSALPNChallenge { + t.Fatal("DisableTLSALPNChallenge should be true for auto_tls/http mode") } } @@ -193,6 +194,71 @@ func TestApplyACMEConfigFromDNSCertMode(t *testing.T) { } } +func TestApplyACMEConfigFromTencentCloudDNSCertMode(t *testing.T) { + var tlsOptions option.InboundTLSOptions + ok := applyACMEConfig(&tlsOptions, &XCertConfig{ + CertMode: "dns", + Domain: "code.littlediary.cn", + DNSProvider: "tencentcloud", + DNSEnv: map[string]string{ + "TENCENTCLOUD_SECRET_ID": "sid", + "TENCENTCLOUD_SECRET_KEY": "skey", + }, + }, false, "code.littlediary.cn", 45365) + if !ok { + t.Fatal("applyACMEConfig() returned false") + } + if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil { + t.Fatal("DNS01Challenge not configured") + } + if tlsOptions.ACME.DNS01Challenge.Provider != C.DNSProviderTencentCloud { + t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider) + } + if tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretID != "sid" { + t.Fatalf("TencentCloud SecretID = %q", tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretID) + } + if tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretKey != "skey" { + t.Fatalf("TencentCloud SecretKey = %q", tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretKey) + } + if tlsOptions.ACME.AlternativeTLSPort != 45365 { + t.Fatalf("AlternativeTLSPort = %d, want 45365", tlsOptions.ACME.AlternativeTLSPort) + } +} + +func TestApplyACMEConfigFromDNSPodAliasWithTencentCredentials(t *testing.T) { + var tlsOptions option.InboundTLSOptions + ok := applyACMEConfig(&tlsOptions, &XCertConfig{ + CertMode: "dns", + Domain: "code.littlediary.cn", + DNSProvider: "dnspod", + DNSEnv: map[string]string{ + "TENCENTCLOUD_SECRET_ID": "sid", + "TENCENTCLOUD_SECRET_KEY": "skey", + }, + }, false, "code.littlediary.cn", 443) + if !ok { + t.Fatal("applyACMEConfig() returned false") + } + if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil { + t.Fatal("DNS01Challenge not configured") + } + if tlsOptions.ACME.DNS01Challenge.Provider != C.DNSProviderTencentCloud { + t.Fatalf("DNS provider = %q, want %q", tlsOptions.ACME.DNS01Challenge.Provider, C.DNSProviderTencentCloud) + } +} + +func TestMergedTLSSettingsUsesTopLevelServerName(t *testing.T) { + tlsSettings := mergedTLSSettings(XInnerConfig{}, &XNodeConfig{ + ServerName: "code.littlediary.cn", + }) + if tlsSettings == nil { + t.Fatal("mergedTLSSettings() returned nil") + } + if tlsSettings.ServerName != "code.littlediary.cn" { + t.Fatalf("ServerName = %q, want %q", tlsSettings.ServerName, "code.littlediary.cn") + } +} + func TestHasUsableServerTLS(t *testing.T) { if hasUsableServerTLS(option.InboundTLSOptions{}) { t.Fatal("empty TLS options should not be usable") From 1fb5f5fb129292931b86de6859cb6e177d54f75e Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 19:01:00 +0800 Subject: [PATCH 85/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/install.sh b/install.sh index 11b9a199..036321a8 100644 --- a/install.sh +++ b/install.sh @@ -90,6 +90,12 @@ build_sing_box() { VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") # Reduced tags for safer build on smaller servers TAGS="with_quic,with_utls,with_clash_api,with_gvisor,with_acme" + + echo -e "${YELLOW}Downloading Go modules and refreshing go.sum entries...${NC}" + if ! go mod download; then + echo -e "${RED}Failed to download Go modules.${NC}" + exit 1 + fi echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" echo -e "${YELLOW}Note: Using -p 1 to save memory and avoid silent crashes.${NC}" From 7a3126fc85a19354567ef3745c43ddaada84941c Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 19:14:47 +0800 Subject: [PATCH 86/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8DACME=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- install.sh | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 01dee62b..92bf4fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,10 @@ sing-box.exe *.dll *.so *.dylib - +.codex-go-Cache +.codex-gopath +.codex-go-Build +.codex-go-modcache # Environment .env .env.local diff --git a/install.sh b/install.sh index 036321a8..73b4565a 100644 --- a/install.sh +++ b/install.sh @@ -96,6 +96,14 @@ build_sing_box() { echo -e "${RED}Failed to download Go modules.${NC}" exit 1 fi + if ! go get github.com/libdns/dnspod@v0.0.3 github.com/libdns/tencentcloud@v1.4.3; then + echo -e "${RED}Failed to download ACME DNS provider modules.${NC}" + exit 1 + fi + if ! go mod tidy; then + echo -e "${RED}Failed to refresh Go module metadata.${NC}" + exit 1 + fi echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" echo -e "${YELLOW}Note: Using -p 1 to save memory and avoid silent crashes.${NC}" From 13cbccafcb5ffa92ced1c7f54bf118e9d05c4c79 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 19:29:28 +0800 Subject: [PATCH 87/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/dnspod/provider.go | 332 ++++++++++++++++++++++++++++++++++++++ common/tls/acme.go | 4 +- go.mod | 1 - go.sum | 5 + service/acme/service.go | 4 +- 5 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 common/dnspod/provider.go diff --git a/common/dnspod/provider.go b/common/dnspod/provider.go new file mode 100644 index 00000000..7524a16d --- /dev/null +++ b/common/dnspod/provider.go @@ -0,0 +1,332 @@ +package dnspod + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/libdns/libdns" + E "github.com/sagernet/sing/common/exceptions" +) + +const defaultAPIEndpoint = "https://dnsapi.cn" + +type Provider struct { + APIToken string + APIEndpoint string + HTTPClient *http.Client +} + +type apiStatus struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type apiRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + TTL string `json:"ttl"` + Value string `json:"value"` +} + +type createRecordResponse struct { + Status apiStatus `json:"status"` + Record struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"record"` +} + +type listRecordsResponse struct { + Status apiStatus `json:"status"` + Records []apiRecord `json:"records"` +} + +func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { + records, err := p.listRecords(ctx, normalizeZone(zone), "", "") + if err != nil { + return nil, err + } + results := make([]libdns.Record, 0, len(records)) + for _, record := range records { + results = append(results, record.toLibdnsRecord()) + } + return results, nil +} + +func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + zone = normalizeZone(zone) + if zone == "" { + return nil, E.New("DNSPod zone is empty") + } + if strings.TrimSpace(p.APIToken) == "" { + return nil, E.New("DNSPod API token is empty") + } + created := make([]libdns.Record, 0, len(records)) + for _, record := range records { + requestRecord, err := normalizeInputRecord(record) + if err != nil { + return created, err + } + params := url.Values{ + "domain": []string{zone}, + "sub_domain": []string{requestRecord.Name}, + "record_type": []string{requestRecord.Type}, + "record_line": []string{"默认"}, + "value": []string{requestRecord.Value}, + } + if requestRecord.TTL > 0 { + params.Set("ttl", strconv.FormatInt(int64(requestRecord.TTL/time.Second), 10)) + } + var response createRecordResponse + err = p.doForm(ctx, "Record.Create", params, &response) + if err != nil { + if requestRecord.Type == "TXT" && strings.Contains(err.Error(), "104") { + existing, listErr := p.listRecords(ctx, zone, requestRecord.Name, requestRecord.Type) + if listErr != nil { + return created, err + } + for _, candidate := range existing { + if requestRecord.matches(candidate) { + created = append(created, candidate.toLibdnsRecord()) + err = nil + break + } + } + } + if err != nil { + return created, err + } + continue + } + requestRecord.ID = response.Record.ID + created = append(created, requestRecord.toLibdnsRecord()) + } + return created, nil +} + +func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + zone = normalizeZone(zone) + if zone == "" { + return nil, E.New("DNSPod zone is empty") + } + if strings.TrimSpace(p.APIToken) == "" { + return nil, E.New("DNSPod API token is empty") + } + deleted := make([]libdns.Record, 0, len(records)) + for _, record := range records { + requestRecord, err := normalizeInputRecord(record) + if err != nil { + return deleted, err + } + candidates, err := p.listRecords(ctx, zone, requestRecord.Name, requestRecord.Type) + if err != nil { + return deleted, err + } + for _, candidate := range candidates { + if !requestRecord.matches(candidate) { + continue + } + err = p.doForm(ctx, "Record.Remove", url.Values{ + "domain": []string{zone}, + "record_id": []string{candidate.ID}, + }, nil) + if err != nil { + return deleted, err + } + deleted = append(deleted, candidate.toLibdnsRecord()) + } + } + return deleted, nil +} + +func (p *Provider) listRecords(ctx context.Context, zone, subDomain, recordType string) ([]apiRecord, error) { + params := url.Values{ + "domain": []string{zone}, + } + if subDomain != "" { + params.Set("sub_domain", subDomain) + } + if recordType != "" { + params.Set("record_type", recordType) + } + var response listRecordsResponse + err := p.doForm(ctx, "Record.List", params, &response) + if err != nil { + return nil, err + } + return response.Records, nil +} + +func (p *Provider) doForm(ctx context.Context, action string, params url.Values, target any) error { + endpoint := strings.TrimRight(strings.TrimSpace(p.APIEndpoint), "/") + if endpoint == "" { + endpoint = defaultAPIEndpoint + } + body := url.Values{ + "login_token": []string{strings.TrimSpace(p.APIToken)}, + "format": []string{"json"}, + } + for key, values := range params { + for _, value := range values { + body.Add(key, value) + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/"+action, strings.NewReader(body.Encode())) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := p.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return E.New("DNSPod ", action, " failed: HTTP ", response.StatusCode) + } + data, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if target == nil { + var wrapper struct { + Status apiStatus `json:"status"` + } + if err = json.Unmarshal(data, &wrapper); err != nil { + return err + } + if wrapper.Status.Code != "1" { + return E.New("DNSPod ", action, " failed: ", wrapper.Status.Code, " ", strings.TrimSpace(wrapper.Status.Message)) + } + return nil + } + if err = json.Unmarshal(data, target); err != nil { + return err + } + switch result := target.(type) { + case *createRecordResponse: + if result.Status.Code != "1" { + return E.New("DNSPod ", action, " failed: ", result.Status.Code, " ", strings.TrimSpace(result.Status.Message)) + } + case *listRecordsResponse: + if result.Status.Code != "1" { + return E.New("DNSPod ", action, " failed: ", result.Status.Code, " ", strings.TrimSpace(result.Status.Message)) + } + } + return nil +} + +func normalizeZone(zone string) string { + return strings.TrimSuffix(strings.TrimSpace(zone), ".") +} + +func normalizeRecordName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "@" + } + return strings.TrimSuffix(name, ".") +} + +func normalizeRecordValue(record libdns.Record) string { + switch typed := record.(type) { + case libdns.TXT: + return typed.Text + case *libdns.TXT: + return typed.Text + default: + return record.RR().Data + } +} + +type normalizedRecord struct { + ID string + Name string + Type string + TTL time.Duration + Value string +} + +func normalizeInputRecord(record libdns.Record) (normalizedRecord, error) { + rr := record.RR() + name := normalizeRecordName(rr.Name) + if name == "" { + return normalizedRecord{}, E.New("DNSPod record name is empty") + } + recordType := strings.ToUpper(strings.TrimSpace(rr.Type)) + if recordType == "" { + return normalizedRecord{}, E.New("DNSPod record type is empty") + } + return normalizedRecord{ + Name: name, + Type: recordType, + TTL: rr.TTL, + Value: normalizeRecordValue(record), + }, nil +} + +func (r normalizedRecord) matches(candidate apiRecord) bool { + if normalizeRecordName(candidate.Name) != r.Name { + return false + } + if r.Type != "" && !strings.EqualFold(candidate.Type, r.Type) { + return false + } + if r.Value != "" && candidate.Value != r.Value { + return false + } + if r.TTL > 0 { + candidateTTL, _ := strconv.ParseInt(candidate.TTL, 10, 64) + if time.Duration(candidateTTL)*time.Second != r.TTL { + return false + } + } + return true +} + +func (r normalizedRecord) toLibdnsRecord() libdns.Record { + record := apiRecord{ + ID: r.ID, + Name: r.Name, + Type: r.Type, + TTL: strconv.FormatInt(int64(r.TTL/time.Second), 10), + Value: r.Value, + } + return record.toLibdnsRecord() +} + +func (r apiRecord) toLibdnsRecord() libdns.Record { + ttlSeconds, _ := strconv.ParseInt(strings.TrimSpace(r.TTL), 10, 64) + ttl := time.Duration(ttlSeconds) * time.Second + name := normalizeRecordName(r.Name) + recordType := strings.ToUpper(strings.TrimSpace(r.Type)) + if recordType == "TXT" { + return libdns.TXT{ + Name: name, + TTL: ttl, + Text: r.Value, + } + } + rr := libdns.RR{ + Name: name, + TTL: ttl, + Type: recordType, + Data: r.Value, + } + parsed, err := rr.Parse() + if err != nil { + return rr + } + return parsed +} diff --git a/common/tls/acme.go b/common/tls/acme.go index 02e87e5f..ebc8a470 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + boxdnspod "github.com/sagernet/sing-box/common/dnspod" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -17,7 +18,6 @@ import ( "github.com/libdns/acmedns" "github.com/libdns/alidns" "github.com/libdns/cloudflare" - "github.com/libdns/dnspod" "github.com/libdns/tencentcloud" "github.com/mholt/acmez/v3/acme" "go.uber.org/zap" @@ -106,7 +106,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound Region: dnsOptions.TencentCloudOptions.Region, } case C.DNSProviderDNSPod: - solver.DNSProvider = &dnspod.Provider{ + solver.DNSProvider = &boxdnspod.Provider{ APIToken: dnsOptions.DNSPodOptions.APIToken, } case C.DNSProviderACMEDNS: diff --git a/go.mod b/go.mod index 3b54027a..84e4dc24 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 - github.com/libdns/dnspod v0.0.3 github.com/libdns/libdns v1.1.1 github.com/libdns/tencentcloud v1.4.3 github.com/logrusorgru/aurora v2.0.3+incompatible diff --git a/go.sum b/go.sum index 9f224685..8660e4d2 100644 --- a/go.sum +++ b/go.sum @@ -124,8 +124,12 @@ github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/dnspod v0.0.3/go.mod h1:XLnqMmK7QlLPEbHwcOxbRvlzRvDgaaUlthRNFOPjXPI= +github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/tencentcloud v1.4.3 h1:xJHYLL1TdPeOtUr6Bu6dHTd1TU6/VFm7BFc2EAzAlvc= +github.com/libdns/tencentcloud v1.4.3/go.mod h1:Be9gY3tDa12DuAPU79RV9NZIcjY6qg5s7zKPsP26yAM= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= @@ -142,6 +146,7 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= diff --git a/service/acme/service.go b/service/acme/service.go index fd346e46..1df13f3f 100644 --- a/service/acme/service.go +++ b/service/acme/service.go @@ -17,6 +17,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/certificate" + boxdnspod "github.com/sagernet/sing-box/common/dnspod" "github.com/sagernet/sing-box/common/dialer" boxtls "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -30,7 +31,6 @@ import ( "github.com/caddyserver/zerossl" "github.com/libdns/alidns" "github.com/libdns/cloudflare" - "github.com/libdns/dnspod" "github.com/libdns/libdns" "github.com/libdns/tencentcloud" "github.com/mholt/acmez/v3/acme" @@ -250,7 +250,7 @@ func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger * Region: dnsOptions.TencentCloudOptions.Region, } case C.DNSProviderDNSPod: - solver.DNSProvider = &dnspod.Provider{ + solver.DNSProvider = &boxdnspod.Provider{ APIToken: dnsOptions.DNSPodOptions.APIToken, } case C.DNSProviderACMEDNS: From 33671364c14834575a56aa24a044015bdcb5d31b Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 19:45:50 +0800 Subject: [PATCH 88/97] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/install.sh b/install.sh index 73b4565a..18195257 100644 --- a/install.sh +++ b/install.sh @@ -18,12 +18,11 @@ CONFIG_BASE_FILE="$CONFIG_MERGE_DIR/10-base.json" CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/20-outbounds.json" WORK_DIR="/var/lib/sing-box" BINARY_PATH="/usr/local/bin/sing-box" -SERVICE_NAME="ganclient" +SERVICE_NAME="singbox" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" -LEGACY_SERVICE_NAME="sing-box" -LEGACY_SERVICE_FILE="/etc/systemd/system/${LEGACY_SERVICE_NAME}.service" +LEGACY_SERVICE_NAMES=("ganclient" "sing-box") -echo -e "${GREEN}Welcome to ganclient Installation Script${NC}" +echo -e "${GREEN}Welcome to singbox Installation Script${NC}" # Check root if [[ $EUID -ne 0 ]]; then @@ -135,17 +134,23 @@ install_go build_sing_box cleanup_legacy_service() { - echo -e "${YELLOW}Cleaning up legacy sing-box service if present...${NC}" - if systemctl list-unit-files | grep -q "^${LEGACY_SERVICE_NAME}\.service"; then - systemctl stop "${LEGACY_SERVICE_NAME}" 2>/dev/null || true - systemctl disable "${LEGACY_SERVICE_NAME}" 2>/dev/null || true - fi - if [[ -f "$LEGACY_SERVICE_FILE" ]]; then - rm -f "$LEGACY_SERVICE_FILE" - fi - if [[ -L "/etc/systemd/system/multi-user.target.wants/${LEGACY_SERVICE_NAME}.service" ]]; then - rm -f "/etc/systemd/system/multi-user.target.wants/${LEGACY_SERVICE_NAME}.service" - fi + echo -e "${YELLOW}Cleaning up legacy services if present...${NC}" + for legacy_service_name in "${LEGACY_SERVICE_NAMES[@]}"; do + if [[ "$legacy_service_name" == "$SERVICE_NAME" ]]; then + continue + fi + legacy_service_file="/etc/systemd/system/${legacy_service_name}.service" + if systemctl list-unit-files | grep -q "^${legacy_service_name}\.service"; then + systemctl stop "${legacy_service_name}" 2>/dev/null || true + systemctl disable "${legacy_service_name}" 2>/dev/null || true + fi + if [[ -f "$legacy_service_file" ]]; then + rm -f "$legacy_service_file" + fi + if [[ -L "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" ]]; then + rm -f "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" + fi + done } cleanup_legacy_service @@ -358,7 +363,7 @@ fi echo -e "${YELLOW}Creating systemd service...${NC}" cat > "$SERVICE_FILE" < Date: Wed, 15 Apr 2026 19:52:52 +0800 Subject: [PATCH 89/97] =?UTF-8?q?=E4=BF=AE=E6=94=B9README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 90be2a83..b9f57a21 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ The universal proxy platform. +## Repository Notice + +This repository is a customized fork based on the upstream [SagerNet/sing-box](https://github.com/SagerNet/sing-box) project. + +- Upstream project: `sing-box` +- Upstream documentation: https://sing-box.sagernet.org +- This repository may contain local modifications for Xboard integration, deployment scripts, and protocol handling behavior + +If you are looking for the official project, feature baseline, or upstream release notes, please refer to the upstream `sing-box` repository first. + [![Packaging status](https://repology.org/badge/vertical-allrepos/sing-box.svg)](https://repology.org/project/sing-box/versions) ## Documentation @@ -36,4 +46,4 @@ along with this program. If not, see . In addition, no derivative work may use the name or imply association with this application without prior consent. -``` \ No newline at end of file +``` From 565a788f779389ee8265e2274cde2e6f628cfb0d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 20:00:47 +0800 Subject: [PATCH 90/97] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 437 +++++++++++++++++++- configs/10-base.multi-node.json | 50 +++ configs/10-base.single-node.json | 42 ++ configs/20-outbounds.example.json | 12 + configs/panel-response.anytls-acme-dns.json | 33 ++ configs/panel-response.shadowsocks2022.json | 16 + configs/panel-response.vless-reality.json | 21 + install.sh | 4 - 8 files changed, 593 insertions(+), 22 deletions(-) create mode 100644 configs/10-base.multi-node.json create mode 100644 configs/10-base.single-node.json create mode 100644 configs/20-outbounds.example.json create mode 100644 configs/panel-response.anytls-acme-dns.json create mode 100644 configs/panel-response.shadowsocks2022.json create mode 100644 configs/panel-response.vless-reality.json diff --git a/README.md b/README.md index b9f57a21..5c4fbea5 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,435 @@ -> Sponsored by [Warp](https://go.warp.dev/sing-box), built for coding with multiple AI agents +> Sponsored by CodeX - -Warp sponsorship - +# sing-box Xboard Fork ---- +这是一个基于上游 [SagerNet/sing-box](https://github.com/SagerNet/sing-box) 的定制分支,主要面向 Xboard / UniProxy 面板联动部署场景。 -# sing-box +它保留了上游 `sing-box` 内核能力,同时补充了: -The universal proxy platform. +- Xboard 动态入站服务 +- 多节点托管 +- 面板协议自动识别 +- VLESS / REALITY 面板字段映射 +- Shadowsocks 2022 用户同步与密钥处理 +- AnyTLS / Trojan / Hysteria / TUIC 的本地 TLS / ACME 支持 +- 安装脚本与分离式配置模板 +- PROXY protocol 客户端真实 IP 传递 -## Repository Notice +如果你要查看官方功能基线、通用配置文档、原始内核行为,请优先参考上游: -This repository is a customized fork based on the upstream [SagerNet/sing-box](https://github.com/SagerNet/sing-box) project. +- 上游仓库:https://github.com/SagerNet/sing-box +- 上游文档:https://sing-box.sagernet.org -- Upstream project: `sing-box` -- Upstream documentation: https://sing-box.sagernet.org -- This repository may contain local modifications for Xboard integration, deployment scripts, and protocol handling behavior +## 适用场景 -If you are looking for the official project, feature baseline, or upstream release notes, please refer to the upstream `sing-box` repository first. +这个仓库更适合以下用途: -[![Packaging status](https://repology.org/badge/vertical-allrepos/sing-box.svg)](https://repology.org/project/sing-box/versions) +- 从 Xboard / UniProxy 面板拉取节点配置 +- 从面板拉取用户并动态更新 +- 单机运行多个节点 +- 给面板型机场节点准备安装脚本 +- 对接面板下发的 `protocol`、`cert_config`、`accept_proxy_protocol`、REALITY 字段等 -## Documentation +## 主要特性 -https://sing-box.sagernet.org +- `services.xboard` 动态服务 +- 支持单节点和多节点 +- 协议优先从面板返回的 `protocol` 识别 +- 支持面板回包控制 `accept_proxy_protocol` +- 支持 VLESS REALITY 的 `server_name`、`public_key`、`private_key`、`short_id` +- 支持 ACME: + - `auto_tls` + - `cert_mode = http` + - `cert_mode = dns` +- 当前已支持的 ACME DNS provider: + - `cloudflare` + - `alidns` + - `tencentcloud` + - `dnspod` + - `acmedns` +- 安装脚本默认生成: + - `/etc/sing-box/config.d/10-base.json` + - `/etc/sing-box/config.d/20-outbounds.json` +- 安装后的服务名为: + - `singbox.service` + +## 仓库内关键文件 + +- [install.sh](./install.sh) + Linux 安装脚本 +- [option/xboard.go](./option/xboard.go) + `services.xboard` 配置结构 +- [service/xboard/service.go](./service/xboard/service.go) + Xboard 动态服务实现 +- [configs](./configs) + 配置示例目录 + +## 快速开始 + +### 1. 编译并安装 + +在 Linux 服务器上进入仓库目录: + +```bash +chmod +x install.sh +./install.sh +``` + +脚本会做这些事情: + +1. 检查 Go 环境 +2. 编译当前仓库代码 +3. 生成分离配置 +4. 创建 `singbox.service` +5. 启动服务 + +### 2. 安装过程会询问的内容 + +- `Panel URL` +- `Panel Token` +- 一个或多个 `Node ID` +- DNS 模式: + - `udp` + - `local` +- 当前节点前面是否有发送 PROXY protocol 的四层代理 + +### 3. 多节点输入规则 + +- 安装脚本会持续要求输入 `Node ID` +- 输入 `NO` 才结束 +- 至少要有一个节点 + +## 运行与管理 + +查看状态: + +```bash +systemctl status singbox +``` + +查看日志: + +```bash +journalctl -u singbox -f +``` + +重启服务: + +```bash +systemctl restart singbox +``` + +手动运行: + +```bash +sing-box -D /var/lib/sing-box -C /etc/sing-box/config.d run +``` + +## 推荐配置结构 + +建议把配置拆成两部分。 + +### `10-base.json` + +放这些内容: + +- 日志 +- DNS +- `services` +- 基础路由规则 + +### `20-outbounds.json` + +放这些内容: + +- 全部出站 +- 出站标签 +- 你自己的分流依赖 + +这样更方便: + +- 面板动态服务和你的自定义出站分离 +- 安装脚本生成的基础配置不容易被误改 +- 调整出站时不会影响 Xboard 服务主体 + +示例已放在: + +- [configs/10-base.single-node.json](./configs/10-base.single-node.json) +- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json) +- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json) + +## `services.xboard` 配置说明 + +配置结构定义见 [option/xboard.go](./option/xboard.go)。 + +### 最小单节点示例 + +```json +{ + "type": "xboard", + "panel_url": "https://panel.example.com", + "key": "replace-with-node-token", + "sync_interval": "1m", + "report_interval": "1m", + "node_id": 286 +} +``` + +### 多节点示例 + +```json +{ + "type": "xboard", + "panel_url": "https://panel.example.com", + "key": "replace-with-node-token", + "sync_interval": "1m", + "report_interval": "1m", + "nodes": [ + { + "node_id": 286 + }, + { + "node_id": 774 + } + ] +} +``` + +### 当前推荐做法 + +- 只传 `panel_url` +- 只传 `key` +- 只传 `node_id` 或 `nodes` + +下面这些字段仍然保留兼容,但常规部署一般不需要: + +- `config_panel_url` +- `user_panel_url` +- `config_node_id` +- `user_node_id` +- `node_type` + +说明: + +- 当前逻辑会优先从面板回包中的 `protocol` 自动识别协议 +- `node_type` 更适合做历史兼容,不建议再依赖它做主配置 + +## 面板配置回包约定 + +### 1. 协议识别 + +推荐面板显式返回: + +```json +{ + "protocol": "vless" +} +``` + +当前服务会按如下顺序识别协议: + +1. `protocol` +2. `node_type` +3. 如果是 Shadowsocks 且存在 `cipher`,则回退识别为 `shadowsocks` + +### 2. 监听地址与端口 + +推荐面板返回: + +```json +{ + "listen_ip": "0.0.0.0", + "server_port": 443 +} +``` + +### 3. PROXY protocol + +如果前面有四层代理,并且它会发送 PROXY protocol 头,需要面板返回: + +```json +{ + "accept_proxy_protocol": true +} +``` + +如果用户是直连节点,不要开启这个字段,否则连接会失败。 + +### 4. VLESS REALITY + +当前支持从面板获取这些字段: + +- `tls_settings.server_name` +- `tls_settings.public_key` +- `tls_settings.private_key` +- `tls_settings.short_id` +- 顶层 `server_name` +- 顶层 `public_key` +- 顶层 `private_key` +- 顶层 `short_id` + +示例见: + +- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json) + +### 5. Shadowsocks 2022 + +推荐面板返回: + +- `protocol: "shadowsocks"` +- `cipher` +- `server_key` + +示例见: + +- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json) + +### 6. AnyTLS / Trojan / Hysteria / TUIC 的证书要求 + +这些协议通常要求本地具备可用 TLS 证书。当前支持: + +- 文件证书 +- 证书内容直传 +- ACME 自动签发 + +如果面板没有下发可用证书,也没有有效 ACME 配置,常见报错是: + +```text +Xboard setup error: missing certificate +``` + +示例见: + +- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json) + +## ACME 说明 + +当前支持: + +- `auto_tls: true` +- `cert_mode: "http"` +- `cert_mode: "dns"` + +### DNS-01 provider + +已支持: + +- `cloudflare` +- `alidns` +- `tencentcloud` +- `dnspod` +- `acmedns` + +### DNSPod 说明 + +仓库已经内置 DNSPod provider 适配,不再依赖旧版 `github.com/libdns/dnspod` 的接口兼容。 + +你可以从面板下发: + +```json +{ + "cert_config": { + "cert_mode": "dns", + "dns_provider": "dnspod", + "dns_env": { + "DNSPOD_TOKEN": "id,token" + } + } +} +``` + +也可以下发腾讯云凭据: + +```json +{ + "cert_config": { + "cert_mode": "dns", + "dns_provider": "tencentcloud", + "dns_env": { + "TENCENTCLOUD_SECRET_ID": "xxx", + "TENCENTCLOUD_SECRET_KEY": "xxx" + } + } +} +``` + +## 配置示例目录 + +[configs](./configs) 目录中已经提供了这些示例: + +- [configs/10-base.single-node.json](./configs/10-base.single-node.json) + 单节点基础配置 +- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json) + 多节点基础配置 +- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json) + 出站配置模板 +- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json) + VLESS REALITY 面板回包 +- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json) + Shadowsocks 2022 面板回包 +- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json) + AnyTLS + ACME DNS 验证面板回包 + +## 常见问题 + +### `unsupported protocol: empty` + +原因通常是: + +- 面板没有返回 `protocol` +- 兼容字段 `node_type` 也为空 + +建议: + +- 面板显式返回顶层 `protocol` + +### `Xboard setup error: missing certificate` + +原因通常是: + +- AnyTLS / Trojan / Hysteria / TUIC 需要证书 +- 面板没有下发证书文件、证书内容或 ACME 参数 + +### `TLS handshake: REALITY: processed invalid connection` + +多数情况下表示客户端参数和当前 REALITY 节点配置不匹配,例如: + +- `server_name` 错误 +- `public_key` 错误 +- `short_id` 错误 +- 客户端实际上不是按 REALITY 模式连入 + +### `Server does not exist` + +通常是: + +- 面板里不存在该 `node_id` +- `token` 不匹配 +- 拉取配置或拉取用户的节点 ID 写错 + +### 真实 IP 没有正确获取 + +请确认: + +1. 前置代理确实发送了 PROXY protocol +2. 面板确实下发了 `accept_proxy_protocol: true` + +否则服务只能看到上游代理 IP。 + +## 开发验证 + +近期已验证通过的命令: + +```bash +go test ./common/dnspod ./common/tls ./service/acme ./service/xboard +go build -trimpath -tags 'with_quic,with_utls,with_clash_api,with_gvisor,with_acme' ./cmd/sing-box +``` + +如果你改动了 Xboard、协议映射、ACME 或证书相关逻辑,建议至少执行一次以上命令。 ## License -``` +```text Copyright (C) 2022 by nekohasekai This program is free software: you can redistribute it and/or modify @@ -38,7 +439,7 @@ the Free Software Foundation, either version 3 of the License, or 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 +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License diff --git a/configs/10-base.multi-node.json b/configs/10-base.multi-node.json new file mode 100644 index 00000000..185fe0d3 --- /dev/null +++ b/configs/10-base.multi-node.json @@ -0,0 +1,50 @@ +{ + "log": { + "level": "info", + "timestamp": true + }, + "experimental": { + "cache_file": { + "enabled": true, + "path": "/var/lib/sing-box/cache.db" + } + }, + "dns": { + "servers": [ + { + "tag": "dns-local", + "type": "local" + } + ] + }, + "services": [ + { + "type": "xboard", + "panel_url": "https://panel.example.com", + "key": "replace-with-node-token", + "sync_interval": "1m", + "report_interval": "1m", + "nodes": [ + { + "node_id": 286 + }, + { + "node_id": 774 + }, + { + "node_id": 815 + } + ] + } + ], + "inbounds": [], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "auto_detect_interface": true + } +} diff --git a/configs/10-base.single-node.json b/configs/10-base.single-node.json new file mode 100644 index 00000000..749920cf --- /dev/null +++ b/configs/10-base.single-node.json @@ -0,0 +1,42 @@ +{ + "log": { + "level": "info", + "timestamp": true + }, + "experimental": { + "cache_file": { + "enabled": true, + "path": "/var/lib/sing-box/cache.db" + } + }, + "dns": { + "servers": [ + { + "tag": "dns-upstream", + "type": "udp", + "server": "1.1.1.1", + "server_port": 53 + } + ] + }, + "services": [ + { + "type": "xboard", + "panel_url": "https://panel.example.com", + "key": "replace-with-node-token", + "sync_interval": "1m", + "report_interval": "1m", + "node_id": 286 + } + ], + "inbounds": [], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "auto_detect_interface": true + } +} diff --git a/configs/20-outbounds.example.json b/configs/20-outbounds.example.json new file mode 100644 index 00000000..5573444d --- /dev/null +++ b/configs/20-outbounds.example.json @@ -0,0 +1,12 @@ +{ + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "block", + "tag": "block" + } + ] +} diff --git a/configs/panel-response.anytls-acme-dns.json b/configs/panel-response.anytls-acme-dns.json new file mode 100644 index 00000000..347f717d --- /dev/null +++ b/configs/panel-response.anytls-acme-dns.json @@ -0,0 +1,33 @@ +{ + "protocol": "anytls", + "listen_ip": "0.0.0.0", + "server_port": 45365, + "network": null, + "networkSettings": null, + "server_name": "code.example.com", + "accept_proxy_protocol": false, + "padding_scheme": [ + "stop=8", + "0=30-30", + "1=100-400", + "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000", + "3=9-9,500-1000", + "4=500-1000", + "5=500-1000", + "6=500-1000", + "7=500-1000" + ], + "cert_config": { + "cert_mode": "dns", + "domain": "code.example.com", + "dns_provider": "tencentcloud", + "dns_env": { + "TENCENTCLOUD_SECRET_ID": "replace-with-secret-id", + "TENCENTCLOUD_SECRET_KEY": "replace-with-secret-key" + } + }, + "base_config": { + "push_interval": 60, + "pull_interval": 60 + } +} diff --git a/configs/panel-response.shadowsocks2022.json b/configs/panel-response.shadowsocks2022.json new file mode 100644 index 00000000..509e8e2d --- /dev/null +++ b/configs/panel-response.shadowsocks2022.json @@ -0,0 +1,16 @@ +{ + "protocol": "shadowsocks", + "listen_ip": "0.0.0.0", + "server_port": 30009, + "network": null, + "networkSettings": null, + "cipher": "2022-blake3-aes-256-gcm", + "plugin": null, + "plugin_opts": null, + "server_key": "NjQzMWVlNjVmYTkwODk0OTMyOTg3MzZmYzczMmFlMTI=", + "accept_proxy_protocol": false, + "base_config": { + "push_interval": 60, + "pull_interval": 60 + } +} diff --git a/configs/panel-response.vless-reality.json b/configs/panel-response.vless-reality.json new file mode 100644 index 00000000..4eb129f6 --- /dev/null +++ b/configs/panel-response.vless-reality.json @@ -0,0 +1,21 @@ +{ + "protocol": "vless", + "listen_ip": "0.0.0.0", + "server_port": 18443, + "network": "tcp", + "tls": 2, + "flow": "xtls-rprx-vision", + "accept_proxy_protocol": false, + "tls_settings": { + "server_name": "git.example.com", + "server_port": "443", + "public_key": "replace-with-client-visible-public-key", + "private_key": "replace-with-server-private-key", + "short_id": "0123456789abcdef", + "allow_insecure": false + }, + "base_config": { + "push_interval": 60, + "pull_interval": 60 + } +} diff --git a/install.sh b/install.sh index 18195257..53f814a5 100644 --- a/install.sh +++ b/install.sh @@ -95,10 +95,6 @@ build_sing_box() { echo -e "${RED}Failed to download Go modules.${NC}" exit 1 fi - if ! go get github.com/libdns/dnspod@v0.0.3 github.com/libdns/tencentcloud@v1.4.3; then - echo -e "${RED}Failed to download ACME DNS provider modules.${NC}" - exit 1 - fi if ! go mod tidy; then echo -e "${RED}Failed to refresh Go module metadata.${NC}" exit 1 From 20d3cb46d46258294e658fea2e291e46ea7ff79f Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 20:09:12 +0800 Subject: [PATCH 91/97] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh => building-install.sh | 0 building-linux.sh | 183 ++++++++++++++++++++ building-windows.ps1 | 276 ++++++++++++++++++++++++++++++ 3 files changed, 459 insertions(+) rename install.sh => building-install.sh (100%) create mode 100644 building-linux.sh create mode 100644 building-windows.ps1 diff --git a/install.sh b/building-install.sh similarity index 100% rename from install.sh rename to building-install.sh diff --git a/building-linux.sh b/building-linux.sh new file mode 100644 index 00000000..cf8fc5a9 --- /dev/null +++ b/building-linux.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" +MAIN_PKG="./cmd/sing-box" +GO_BIN="${GO_BIN:-go}" +CGO_ENABLED_VALUE="${CGO_ENABLED_VALUE:-0}" + +DEFAULT_TARGETS=( + "linux-amd64" + "linux-arm64" + "linux-armv7" + "windows-amd64" + "windows-arm64" + "darwin-amd64" + "darwin-arm64" +) + +usage() { + cat <<'EOF' +Usage: + ./build.sh Build common targets + ./build.sh all Build common targets + ./build.sh linux-amd64 Build a single target + ./build.sh linux-amd64 windows-amd64 + ./build.sh current Build current host platform + +Environment variables: + GO_BIN Go binary path, default: go + DIST_DIR Output directory, default: ./dist + CGO_ENABLED_VALUE CGO_ENABLED value, default: 0 + VERSION Embedded version string, default: git describe --tags --always + BUILD_TAGS_OTHERS Override tags for non-Windows builds + BUILD_TAGS_WINDOWS Override tags for Windows builds +EOF +} + +require_file() { + local file="$1" + if [[ ! -f "$file" ]]; then + echo "Missing required file: $file" >&2 + exit 1 + fi +} + +trim_file() { + local file="$1" + awk 'BEGIN{ORS=""} {gsub(/\r/, ""); print}' "$file" +} + +resolve_version() { + if [[ -n "${VERSION:-}" ]]; then + printf '%s' "$VERSION" + return + fi + if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$ROOT_DIR" describe --tags --always 2>/dev/null || git -C "$ROOT_DIR" rev-parse --short HEAD + return + fi + printf '%s' "custom" +} + +build_target() { + local target="$1" + local goos goarch goarm="" output tags + + case "$target" in + linux-amd64) + goos="linux" + goarch="amd64" + output="$DIST_DIR/sing-box-linux-amd64" + tags="$BUILD_TAGS_OTHERS" + ;; + linux-arm64) + goos="linux" + goarch="arm64" + output="$DIST_DIR/sing-box-linux-arm64" + tags="$BUILD_TAGS_OTHERS" + ;; + linux-armv7) + goos="linux" + goarch="arm" + goarm="7" + output="$DIST_DIR/sing-box-linux-armv7" + tags="$BUILD_TAGS_OTHERS" + ;; + windows-amd64) + goos="windows" + goarch="amd64" + output="$DIST_DIR/sing-box-windows-amd64.exe" + tags="$BUILD_TAGS_WINDOWS" + ;; + windows-arm64) + goos="windows" + goarch="arm64" + output="$DIST_DIR/sing-box-windows-arm64.exe" + tags="$BUILD_TAGS_WINDOWS" + ;; + darwin-amd64) + goos="darwin" + goarch="amd64" + output="$DIST_DIR/sing-box-darwin-amd64" + tags="$BUILD_TAGS_OTHERS" + ;; + darwin-arm64) + goos="darwin" + goarch="arm64" + output="$DIST_DIR/sing-box-darwin-arm64" + tags="$BUILD_TAGS_OTHERS" + ;; + current) + goos="$("$GO_BIN" env GOOS)" + goarch="$("$GO_BIN" env GOARCH)" + output="$DIST_DIR/sing-box-${goos}-${goarch}" + if [[ "$goos" == "windows" ]]; then + output="${output}.exe" + tags="$BUILD_TAGS_WINDOWS" + else + tags="$BUILD_TAGS_OTHERS" + fi + ;; + *) + echo "Unsupported target: $target" >&2 + exit 1 + ;; + esac + + echo "==> Building $target" + ( + cd "$ROOT_DIR" + export CGO_ENABLED="$CGO_ENABLED_VALUE" + export GOOS="$goos" + export GOARCH="$goarch" + if [[ -n "$goarm" ]]; then + export GOARM="$goarm" + else + unset GOARM 2>/dev/null || true + fi + + "$GO_BIN" build -v -p 1 -trimpath \ + -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION_VALUE' $LDFLAGS_SHARED -s -w -buildid=" \ + -tags "$tags" \ + -o "$output" \ + "$MAIN_PKG" + ) +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +require_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_OTHERS" +require_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_WINDOWS" +require_file "$ROOT_DIR/release/LDFLAGS" + +if ! command -v "$GO_BIN" >/dev/null 2>&1; then + echo "Go binary not found: $GO_BIN" >&2 + exit 1 +fi + +BUILD_TAGS_OTHERS="${BUILD_TAGS_OTHERS:-$(trim_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_OTHERS")}" +BUILD_TAGS_WINDOWS="${BUILD_TAGS_WINDOWS:-$(trim_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_WINDOWS")}" +LDFLAGS_SHARED="$(trim_file "$ROOT_DIR/release/LDFLAGS")" +VERSION_VALUE="$(resolve_version)" + +mkdir -p "$DIST_DIR" + +if [[ "$#" -eq 0 || "${1:-}" == "all" ]]; then + TARGETS=("${DEFAULT_TARGETS[@]}") +else + TARGETS=("$@") +fi + +for target in "${TARGETS[@]}"; do + build_target "$target" +done + +echo +echo "Build completed." +echo "Output directory: $DIST_DIR" diff --git a/building-windows.ps1 b/building-windows.ps1 new file mode 100644 index 00000000..91cdc630 --- /dev/null +++ b/building-windows.ps1 @@ -0,0 +1,276 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$Targets = @("all"), + + [string]$GoBin = "", + [string]$DistDir = "", + [string]$CgoEnabledValue = "0", + [string]$Version = "", + [string]$BuildTagsOthers = "", + [string]$BuildTagsWindows = "", + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$MainPkg = "./cmd/sing-box" +$DefaultTargets = @( + "linux-amd64", + "linux-arm64", + "linux-armv7", + "windows-amd64", + "windows-arm64", + "darwin-amd64", + "darwin-arm64" +) + +function Show-Usage { + @' +Usage: + .\building-windows.ps1 + .\building-windows.ps1 all + .\building-windows.ps1 linux-amd64 + .\building-windows.ps1 linux-amd64 windows-amd64 + .\building-windows.ps1 current + +Optional parameters: + -GoBin Go binary path + -DistDir Output directory, default: .\dist + -CgoEnabledValue <0|1> CGO_ENABLED value, default: 0 + -Version Embedded version, default: git describe --tags --always + -BuildTagsOthers Override non-Windows build tags + -BuildTagsWindows Override Windows build tags +'@ | Write-Host +} + +function Require-File { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Missing required file: $Path" + } +} + +function Read-TrimmedFile { + param([string]$Path) + + return ((Get-Content -LiteralPath $Path -Raw) -replace "`r", "").Trim() +} + +function Resolve-GoBinary { + param([string]$RequestedGoBin) + + if ($RequestedGoBin) { + if (-not (Test-Path -LiteralPath $RequestedGoBin -PathType Leaf)) { + throw "Go binary not found: $RequestedGoBin" + } + return (Resolve-Path -LiteralPath $RequestedGoBin).Path + } + + $command = Get-Command go -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $defaultPath = "C:\Program Files\Go\bin\go.exe" + if (Test-Path -LiteralPath $defaultPath -PathType Leaf) { + return $defaultPath + } + + throw "Go binary not found. Please install Go or pass -GoBin." +} + +function Resolve-Version { + param( + [string]$RequestedVersion, + [string]$RepoRoot + ) + + if ($RequestedVersion) { + return $RequestedVersion + } + + $gitCommand = Get-Command git -ErrorAction SilentlyContinue + if ($gitCommand) { + Push-Location $RepoRoot + try { + $described = git describe --tags --always 2>$null + if ($LASTEXITCODE -eq 0 -and $described) { + return $described.Trim() + } + $commit = git rev-parse --short HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and $commit) { + return $commit.Trim() + } + } + finally { + Pop-Location + } + } + + return "custom" +} + +function Get-TargetConfig { + param([string]$Target) + + switch ($Target) { + "linux-amd64" { + return @{ + GOOS = "linux" + GOARCH = "amd64" + Output = "sing-box-linux-amd64" + Tags = $script:ResolvedBuildTagsOthers + } + } + "linux-arm64" { + return @{ + GOOS = "linux" + GOARCH = "arm64" + Output = "sing-box-linux-arm64" + Tags = $script:ResolvedBuildTagsOthers + } + } + "linux-armv7" { + return @{ + GOOS = "linux" + GOARCH = "arm" + GOARM = "7" + Output = "sing-box-linux-armv7" + Tags = $script:ResolvedBuildTagsOthers + } + } + "windows-amd64" { + return @{ + GOOS = "windows" + GOARCH = "amd64" + Output = "sing-box-windows-amd64.exe" + Tags = $script:ResolvedBuildTagsWindows + } + } + "windows-arm64" { + return @{ + GOOS = "windows" + GOARCH = "arm64" + Output = "sing-box-windows-arm64.exe" + Tags = $script:ResolvedBuildTagsWindows + } + } + "darwin-amd64" { + return @{ + GOOS = "darwin" + GOARCH = "amd64" + Output = "sing-box-darwin-amd64" + Tags = $script:ResolvedBuildTagsOthers + } + } + "darwin-arm64" { + return @{ + GOOS = "darwin" + GOARCH = "arm64" + Output = "sing-box-darwin-arm64" + Tags = $script:ResolvedBuildTagsOthers + } + } + "current" { + $goos = (& $script:ResolvedGoBin env GOOS).Trim() + $goarch = (& $script:ResolvedGoBin env GOARCH).Trim() + $output = "sing-box-$goos-$goarch" + $tags = $script:ResolvedBuildTagsOthers + if ($goos -eq "windows") { + $output += ".exe" + $tags = $script:ResolvedBuildTagsWindows + } + return @{ + GOOS = $goos + GOARCH = $goarch + Output = $output + Tags = $tags + } + } + default { + throw "Unsupported target: $Target" + } + } +} + +function Invoke-BuildTarget { + param([string]$Target) + + $config = Get-TargetConfig -Target $Target + $outputPath = Join-Path $script:ResolvedDistDir $config.Output + + Write-Host "==> Building $Target" -ForegroundColor Cyan + + Push-Location $RootDir + try { + $env:CGO_ENABLED = $CgoEnabledValue + $env:GOOS = $config.GOOS + $env:GOARCH = $config.GOARCH + + if ($config.ContainsKey("GOARM")) { + $env:GOARM = $config.GOARM + } else { + Remove-Item Env:GOARM -ErrorAction SilentlyContinue + } + + & $script:ResolvedGoBin build -v -p 1 -trimpath ` + -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$script:ResolvedVersion' $script:ResolvedLdflagsShared -s -w -buildid=" ` + -tags $config.Tags ` + -o $outputPath ` + $MainPkg + + if ($LASTEXITCODE -ne 0) { + throw "Build failed: $Target" + } + } + finally { + Pop-Location + } +} + +if ($Help) { + Show-Usage + exit 0 +} + +if ($Targets.Count -eq 1 -and ($Targets[0] -eq "-h" -or $Targets[0] -eq "--help")) { + Show-Usage + exit 0 +} + +$releaseTagsOthersPath = Join-Path $RootDir "release\DEFAULT_BUILD_TAGS_OTHERS" +$releaseTagsWindowsPath = Join-Path $RootDir "release\DEFAULT_BUILD_TAGS_WINDOWS" +$releaseLdflagsPath = Join-Path $RootDir "release\LDFLAGS" + +Require-File -Path $releaseTagsOthersPath +Require-File -Path $releaseTagsWindowsPath +Require-File -Path $releaseLdflagsPath + +$script:ResolvedGoBin = Resolve-GoBinary -RequestedGoBin $GoBin +$script:ResolvedDistDir = if ($DistDir) { $DistDir } else { Join-Path $RootDir "dist" } +$script:ResolvedDistDir = [System.IO.Path]::GetFullPath($script:ResolvedDistDir) +$script:ResolvedVersion = Resolve-Version -RequestedVersion $Version -RepoRoot $RootDir +$script:ResolvedBuildTagsOthers = if ($BuildTagsOthers) { $BuildTagsOthers } else { Read-TrimmedFile -Path $releaseTagsOthersPath } +$script:ResolvedBuildTagsWindows = if ($BuildTagsWindows) { $BuildTagsWindows } else { Read-TrimmedFile -Path $releaseTagsWindowsPath } +$script:ResolvedLdflagsShared = Read-TrimmedFile -Path $releaseLdflagsPath + +New-Item -ItemType Directory -Force -Path $script:ResolvedDistDir | Out-Null + +$resolvedTargets = @() +if ($Targets.Count -eq 0 -or ($Targets.Count -eq 1 -and $Targets[0] -eq "all")) { + $resolvedTargets = $DefaultTargets +} else { + $resolvedTargets = $Targets +} + +foreach ($target in $resolvedTargets) { + Invoke-BuildTarget -Target $target +} + +Write-Host "" +Write-Host "Build completed." -ForegroundColor Green +Write-Host "Output directory: $script:ResolvedDistDir" From d45e84f8375ba01b22526ee29ed377ede85e73ab Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 20:13:53 +0800 Subject: [PATCH 92/97] =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=A4=9A=E6=A0=B8?= =?UTF-8?q?=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- building-install.sh | 22 +++++++++++++++++++--- building-linux.sh | 23 +++++++++++++++++++++-- building-windows.ps1 | 21 +++++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/building-install.sh b/building-install.sh index 53f814a5..17a69da7 100644 --- a/building-install.sh +++ b/building-install.sh @@ -22,6 +22,22 @@ SERVICE_NAME="singbox" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" LEGACY_SERVICE_NAMES=("ganclient" "sing-box") +resolve_build_jobs() { + if [[ -n "${GO_BUILD_JOBS:-}" ]]; then + echo "$GO_BUILD_JOBS" + return + fi + if command -v nproc >/dev/null 2>&1; then + nproc + return + fi + if command -v getconf >/dev/null 2>&1; then + getconf _NPROCESSORS_ONLN + return + fi + echo "1" +} + echo -e "${GREEN}Welcome to singbox Installation Script${NC}" # Check root @@ -71,6 +87,7 @@ install_go() { # Build sing-box build_sing_box() { echo -e "${YELLOW}Building sing-box from source...${NC}" + BUILD_JOBS="$(resolve_build_jobs)" # Check if we are in the source directory if [[ ! -f "go.mod" ]]; then @@ -101,12 +118,11 @@ build_sing_box() { fi echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" - echo -e "${YELLOW}Note: Using -p 1 to save memory and avoid silent crashes.${NC}" + echo -e "${YELLOW}Using Go parallel build jobs: ${BUILD_JOBS}${NC}" # Use -o to be explicit about output location - # Use -p 1 to limit parallel builds (prevent OOM spikes) # Redirect stderr to stdout to see errors clearly - if ! go build -v -p 1 -trimpath \ + if ! go build -v -p "$BUILD_JOBS" -trimpath \ -o "$BINARY_PATH" \ -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" \ -tags "$TAGS" \ diff --git a/building-linux.sh b/building-linux.sh index cf8fc5a9..9831fc83 100644 --- a/building-linux.sh +++ b/building-linux.sh @@ -7,6 +7,7 @@ DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" MAIN_PKG="./cmd/sing-box" GO_BIN="${GO_BIN:-go}" CGO_ENABLED_VALUE="${CGO_ENABLED_VALUE:-0}" +BUILD_JOBS="${GO_BUILD_JOBS:-}" DEFAULT_TARGETS=( "linux-amd64" @@ -31,6 +32,7 @@ Environment variables: GO_BIN Go binary path, default: go DIST_DIR Output directory, default: ./dist CGO_ENABLED_VALUE CGO_ENABLED value, default: 0 + GO_BUILD_JOBS Go build parallel jobs, default: detected CPU core count VERSION Embedded version string, default: git describe --tags --always BUILD_TAGS_OTHERS Override tags for non-Windows builds BUILD_TAGS_WINDOWS Override tags for Windows builds @@ -62,6 +64,22 @@ resolve_version() { printf '%s' "custom" } +resolve_build_jobs() { + if [[ -n "$BUILD_JOBS" ]]; then + printf '%s' "$BUILD_JOBS" + return + fi + if command -v nproc >/dev/null 2>&1; then + nproc + return + fi + if command -v getconf >/dev/null 2>&1; then + getconf _NPROCESSORS_ONLN + return + fi + printf '%s' "1" +} + build_target() { local target="$1" local goos goarch goarm="" output tags @@ -127,7 +145,7 @@ build_target() { ;; esac - echo "==> Building $target" + echo "==> Building $target (jobs: $RESOLVED_BUILD_JOBS)" ( cd "$ROOT_DIR" export CGO_ENABLED="$CGO_ENABLED_VALUE" @@ -139,7 +157,7 @@ build_target() { unset GOARM 2>/dev/null || true fi - "$GO_BIN" build -v -p 1 -trimpath \ + "$GO_BIN" build -v -p "$RESOLVED_BUILD_JOBS" -trimpath \ -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION_VALUE' $LDFLAGS_SHARED -s -w -buildid=" \ -tags "$tags" \ -o "$output" \ @@ -165,6 +183,7 @@ BUILD_TAGS_OTHERS="${BUILD_TAGS_OTHERS:-$(trim_file "$ROOT_DIR/release/DEFAULT_B BUILD_TAGS_WINDOWS="${BUILD_TAGS_WINDOWS:-$(trim_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_WINDOWS")}" LDFLAGS_SHARED="$(trim_file "$ROOT_DIR/release/LDFLAGS")" VERSION_VALUE="$(resolve_version)" +RESOLVED_BUILD_JOBS="$(resolve_build_jobs)" mkdir -p "$DIST_DIR" diff --git a/building-windows.ps1 b/building-windows.ps1 index 91cdc630..5a760766 100644 --- a/building-windows.ps1 +++ b/building-windows.ps1 @@ -6,6 +6,7 @@ param( [string]$GoBin = "", [string]$DistDir = "", [string]$CgoEnabledValue = "0", + [string]$BuildJobs = "", [string]$Version = "", [string]$BuildTagsOthers = "", [string]$BuildTagsWindows = "", @@ -40,6 +41,7 @@ Optional parameters: -GoBin Go binary path -DistDir Output directory, default: .\dist -CgoEnabledValue <0|1> CGO_ENABLED value, default: 0 + -BuildJobs Go build parallel jobs, default: GO_BUILD_JOBS or CPU core count -Version Embedded version, default: git describe --tags --always -BuildTagsOthers Override non-Windows build tags -BuildTagsWindows Override Windows build tags @@ -114,6 +116,20 @@ function Resolve-Version { return "custom" } +function Resolve-BuildJobs { + param([string]$RequestedBuildJobs) + + if ($RequestedBuildJobs) { + return $RequestedBuildJobs + } + + if ($env:GO_BUILD_JOBS) { + return $env:GO_BUILD_JOBS + } + + return [string][Environment]::ProcessorCount +} + function Get-TargetConfig { param([string]$Target) @@ -203,7 +219,7 @@ function Invoke-BuildTarget { $config = Get-TargetConfig -Target $Target $outputPath = Join-Path $script:ResolvedDistDir $config.Output - Write-Host "==> Building $Target" -ForegroundColor Cyan + Write-Host "==> Building $Target (jobs: $script:ResolvedBuildJobs)" -ForegroundColor Cyan Push-Location $RootDir try { @@ -217,7 +233,7 @@ function Invoke-BuildTarget { Remove-Item Env:GOARM -ErrorAction SilentlyContinue } - & $script:ResolvedGoBin build -v -p 1 -trimpath ` + & $script:ResolvedGoBin build -v -p $script:ResolvedBuildJobs -trimpath ` -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$script:ResolvedVersion' $script:ResolvedLdflagsShared -s -w -buildid=" ` -tags $config.Tags ` -o $outputPath ` @@ -253,6 +269,7 @@ Require-File -Path $releaseLdflagsPath $script:ResolvedGoBin = Resolve-GoBinary -RequestedGoBin $GoBin $script:ResolvedDistDir = if ($DistDir) { $DistDir } else { Join-Path $RootDir "dist" } $script:ResolvedDistDir = [System.IO.Path]::GetFullPath($script:ResolvedDistDir) +$script:ResolvedBuildJobs = Resolve-BuildJobs -RequestedBuildJobs $BuildJobs $script:ResolvedVersion = Resolve-Version -RequestedVersion $Version -RepoRoot $RootDir $script:ResolvedBuildTagsOthers = if ($BuildTagsOthers) { $BuildTagsOthers } else { Read-TrimmedFile -Path $releaseTagsOthersPath } $script:ResolvedBuildTagsWindows = if ($BuildTagsWindows) { $BuildTagsWindows } else { Read-TrimmedFile -Path $releaseTagsWindowsPath } From 33c2df5485707f60ddfd12b0b5cb40a5516f5bb2 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 20:37:42 +0800 Subject: [PATCH 93/97] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=92=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=BC=96=E8=AF=91=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- building-windows.ps1 | 74 ++++++++++++++++++++++++++++- dns/transport/local/local_darwin.go | 17 +++++++ dns/transport/local/local_shared.go | 2 +- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/building-windows.ps1 b/building-windows.ps1 index 5a760766..2988ff1f 100644 --- a/building-windows.ps1 +++ b/building-windows.ps1 @@ -5,6 +5,8 @@ param( [string]$GoBin = "", [string]$DistDir = "", + [string]$GoCacheDir = "", + [string]$GoModCacheDir = "", [string]$CgoEnabledValue = "0", [string]$BuildJobs = "", [string]$Version = "", @@ -40,6 +42,8 @@ Usage: Optional parameters: -GoBin Go binary path -DistDir Output directory, default: .\dist + -GoCacheDir Go build cache directory, default: .\.cache\go-build + -GoModCacheDir Go module cache directory, default: .\.cache\gomod -CgoEnabledValue <0|1> CGO_ENABLED value, default: 0 -BuildJobs Go build parallel jobs, default: GO_BUILD_JOBS or CPU core count -Version Embedded version, default: git describe --tags --always @@ -130,6 +134,19 @@ function Resolve-BuildJobs { return [string][Environment]::ProcessorCount } +function Resolve-CachePath { + param( + [string]$RequestedPath, + [string]$DefaultRelativePath + ) + + if ($RequestedPath) { + return [System.IO.Path]::GetFullPath($RequestedPath) + } + + return [System.IO.Path]::GetFullPath((Join-Path $RootDir $DefaultRelativePath)) +} + function Get-TargetConfig { param([string]$Target) @@ -226,6 +243,8 @@ function Invoke-BuildTarget { $env:CGO_ENABLED = $CgoEnabledValue $env:GOOS = $config.GOOS $env:GOARCH = $config.GOARCH + $env:GOCACHE = $script:ResolvedGoCacheDir + $env:GOMODCACHE = $script:ResolvedGoModCacheDir if ($config.ContainsKey("GOARM")) { $env:GOARM = $config.GOARM @@ -240,7 +259,27 @@ function Invoke-BuildTarget { $MainPkg if ($LASTEXITCODE -ne 0) { - throw "Build failed: $Target" + return [pscustomobject]@{ + Target = $Target + Success = $false + OutputPath = $outputPath + Error = "go build exited with code $LASTEXITCODE" + } + } + + return [pscustomobject]@{ + Target = $Target + Success = $true + OutputPath = $outputPath + Error = "" + } + } + catch { + return [pscustomobject]@{ + Target = $Target + Success = $false + OutputPath = $outputPath + Error = $_.Exception.Message } } finally { @@ -269,6 +308,8 @@ Require-File -Path $releaseLdflagsPath $script:ResolvedGoBin = Resolve-GoBinary -RequestedGoBin $GoBin $script:ResolvedDistDir = if ($DistDir) { $DistDir } else { Join-Path $RootDir "dist" } $script:ResolvedDistDir = [System.IO.Path]::GetFullPath($script:ResolvedDistDir) +$script:ResolvedGoCacheDir = Resolve-CachePath -RequestedPath $GoCacheDir -DefaultRelativePath ".cache\go-build" +$script:ResolvedGoModCacheDir = Resolve-CachePath -RequestedPath $GoModCacheDir -DefaultRelativePath ".cache\gomod" $script:ResolvedBuildJobs = Resolve-BuildJobs -RequestedBuildJobs $BuildJobs $script:ResolvedVersion = Resolve-Version -RequestedVersion $Version -RepoRoot $RootDir $script:ResolvedBuildTagsOthers = if ($BuildTagsOthers) { $BuildTagsOthers } else { Read-TrimmedFile -Path $releaseTagsOthersPath } @@ -276,6 +317,8 @@ $script:ResolvedBuildTagsWindows = if ($BuildTagsWindows) { $BuildTagsWindows } $script:ResolvedLdflagsShared = Read-TrimmedFile -Path $releaseLdflagsPath New-Item -ItemType Directory -Force -Path $script:ResolvedDistDir | Out-Null +New-Item -ItemType Directory -Force -Path $script:ResolvedGoCacheDir | Out-Null +New-Item -ItemType Directory -Force -Path $script:ResolvedGoModCacheDir | Out-Null $resolvedTargets = @() if ($Targets.Count -eq 0 -or ($Targets.Count -eq 1 -and $Targets[0] -eq "all")) { @@ -284,10 +327,37 @@ if ($Targets.Count -eq 0 -or ($Targets.Count -eq 1 -and $Targets[0] -eq "all")) $resolvedTargets = $Targets } +$results = @() foreach ($target in $resolvedTargets) { - Invoke-BuildTarget -Target $target + $results += Invoke-BuildTarget -Target $target } Write-Host "" Write-Host "Build completed." -ForegroundColor Green Write-Host "Output directory: $script:ResolvedDistDir" +Write-Host "Go build cache: $script:ResolvedGoCacheDir" +Write-Host "Go module cache: $script:ResolvedGoModCacheDir" + +$successfulResults = @($results | Where-Object { $_.Success }) +$failedResults = @($results | Where-Object { -not $_.Success }) + +Write-Host "" +Write-Host "Succeeded targets:" -ForegroundColor Green +if ($successfulResults.Count -eq 0) { + Write-Host " (none)" +} else { + foreach ($result in $successfulResults) { + Write-Host " $($result.Target) -> $($result.OutputPath)" + } +} + +Write-Host "" +Write-Host "Failed targets:" -ForegroundColor Yellow +if ($failedResults.Count -eq 0) { + Write-Host " (none)" +} else { + foreach ($result in $failedResults) { + Write-Host " $($result.Target) -> $($result.Error)" + } + exit 1 +} diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go index eb33d64f..db155b9d 100644 --- a/dns/transport/local/local_darwin.go +++ b/dns/transport/local/local_darwin.go @@ -90,3 +90,20 @@ func (t *Transport) Reset() { t.dhcpTransport.Reset() } } + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { + addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) + if len(addresses) > 0 { + return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil + } + } + if t.fallback && t.dhcpTransport != nil { + dhcpServers := t.dhcpTransport.Fetch() + if len(dhcpServers) > 0 { + return t.dhcpTransport.Exchange0(ctx, message, dhcpServers) + } + } + return t.exchange(ctx, message, question.Name) +} diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go index 64a23a9f..307524b6 100644 --- a/dns/transport/local/local_shared.go +++ b/dns/transport/local/local_shared.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !windows package local From 1a06f132a6b05fe5b159d5908dc3cf41fe3c1e99 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 21:22:20 +0800 Subject: [PATCH 94/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- install.sh | 336 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 install.sh diff --git a/README.md b/README.md index 5c4fbea5..d3a7853f 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,10 @@ 在 Linux 服务器上进入仓库目录: ```bash -chmod +x install.sh -./install.sh +curl -fsSL https://s3.cloudyun.top/downloads/singbox/install.sh | bash ``` +`install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。 + 脚本会做这些事情: diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..7521c045 --- /dev/null +++ b/install.sh @@ -0,0 +1,336 @@ +#!/bin/bash + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +CONFIG_DIR="/etc/sing-box" +CONFIG_MERGE_DIR="$CONFIG_DIR/config.d" +CONFIG_BASE_FILE="$CONFIG_MERGE_DIR/10-base.json" +CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/20-outbounds.json" +WORK_DIR="/var/lib/sing-box" +BINARY_PATH="/usr/local/bin/sing-box" +SERVICE_NAME="singbox" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +LEGACY_SERVICE_NAMES=("ganclient" "sing-box") +RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://s3.cloudyun.top/downloads/singbox}" +PUBLISHED_SCRIPT_URL="${PUBLISHED_SCRIPT_URL:-https://s3.cloudyun.top/downloads/singbox/install.sh}" + +echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}" +echo -e "${YELLOW}Published install script: ${PUBLISHED_SCRIPT_URL}${NC}" + +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}This script must be run as root${NC}" + exit 1 +fi + +OS="$(uname -s)" +if [[ "$OS" != "Linux" ]]; then + echo -e "${RED}This install script currently supports Linux only. Current OS: ${OS}${NC}" + exit 1 +fi + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) BINARY_ARCH="amd64" ;; + aarch64|arm64) BINARY_ARCH="arm64" ;; + armv7l|armv7) BINARY_ARCH="armv7" ;; + *) + echo -e "${RED}Unsupported architecture: $ARCH${NC}" + exit 1 + ;; +esac + +DOWNLOAD_TARGET="${DOWNLOAD_TARGET:-linux-${BINARY_ARCH}}" +DOWNLOAD_URL="${DOWNLOAD_URL:-${RELEASE_BASE_URL}/sing-box-${DOWNLOAD_TARGET}}" +TMP_BINARY="$(mktemp)" + +mkdir -p "$CONFIG_DIR" +mkdir -p "$CONFIG_MERGE_DIR" +mkdir -p "$WORK_DIR" + +download_binary() { + echo -e "${YELLOW}Downloading sing-box release binary...${NC}" + echo -e "${YELLOW}Target: ${DOWNLOAD_TARGET}${NC}" + echo -e "${YELLOW}URL: ${DOWNLOAD_URL}${NC}" + + if command -v curl >/dev/null 2>&1; then + if ! curl -fL "${DOWNLOAD_URL}" -o "${TMP_BINARY}"; then + echo -e "${RED}Failed to download release binary with curl.${NC}" + rm -f "${TMP_BINARY}" + exit 1 + fi + elif command -v wget >/dev/null 2>&1; then + if ! wget -O "${TMP_BINARY}" "${DOWNLOAD_URL}"; then + echo -e "${RED}Failed to download release binary with wget.${NC}" + rm -f "${TMP_BINARY}" + exit 1 + fi + else + echo -e "${RED}Neither curl nor wget is installed.${NC}" + rm -f "${TMP_BINARY}" + exit 1 + fi + + install -m 0755 "${TMP_BINARY}" "${BINARY_PATH}" + rm -f "${TMP_BINARY}" + + if [[ ! -x "${BINARY_PATH}" ]]; then + echo -e "${RED}Binary install failed: ${BINARY_PATH} not executable.${NC}" + exit 1 + fi + + echo -e "${GREEN}sing-box downloaded and installed to ${BINARY_PATH}${NC}" +} + +cleanup_legacy_service() { + echo -e "${YELLOW}Cleaning up legacy services if present...${NC}" + for legacy_service_name in "${LEGACY_SERVICE_NAMES[@]}"; do + if [[ "$legacy_service_name" == "$SERVICE_NAME" ]]; then + continue + fi + legacy_service_file="/etc/systemd/system/${legacy_service_name}.service" + if systemctl list-unit-files | grep -q "^${legacy_service_name}\.service"; then + systemctl stop "${legacy_service_name}" 2>/dev/null || true + systemctl disable "${legacy_service_name}" 2>/dev/null || true + fi + if [[ -f "$legacy_service_file" ]]; then + rm -f "$legacy_service_file" + fi + if [[ -L "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" ]]; then + rm -f "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" + fi + done +} + +download_binary +cleanup_legacy_service + +if [[ -f ".env" ]]; then + echo -e "${YELLOW}Loading configuration from .env...${NC}" + source .env +fi + +read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL +PANEL_URL=${INPUT_URL:-$PANEL_URL} + +read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN +PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} + +read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL +ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}} + +declare -a NODE_IDS + +i=1 +while true; do + DEFAULT_NODE_ID="" + if [[ "$i" -eq 1 && -n "${NODE_ID:-}" ]]; then + DEFAULT_NODE_ID="$NODE_ID" + fi + if [[ -n "$DEFAULT_NODE_ID" ]]; then + read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type NO to finish): " INPUT_ID + else + read -p "Enter Node ID for node #$i (type NO to finish): " INPUT_ID + fi + CURRENT_NODE_ID=${INPUT_ID:-$DEFAULT_NODE_ID} + if [[ "$CURRENT_NODE_ID" =~ ^([nN][oO])$ ]]; then + if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then + echo -e "${RED}At least one Node ID is required${NC}" + exit 1 + fi + break + fi + if [[ -z "$CURRENT_NODE_ID" ]]; then + echo -e "${RED}Node ID is required for node #$i${NC}" + exit 1 + fi + if ! [[ "$CURRENT_NODE_ID" =~ ^[0-9]+$ ]]; then + echo -e "${RED}Node ID must be a positive integer${NC}" + exit 1 + fi + NODE_IDS+=("$CURRENT_NODE_ID") + ((i++)) +done + +NODE_COUNT=${#NODE_IDS[@]} + +DNS_MODE_DEFAULT=${DNS_MODE:-udp} +read -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE +DNS_MODE=$(echo "${INPUT_DNS_MODE:-$DNS_MODE_DEFAULT}" | tr '[:upper:]' '[:lower:]') + +case "$DNS_MODE" in + udp) + DNS_SERVER_DEFAULT=${DNS_SERVER:-1.1.1.1} + DNS_SERVER_PORT_DEFAULT=${DNS_SERVER_PORT:-53} + read -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER + DNS_SERVER=${INPUT_DNS_SERVER:-$DNS_SERVER_DEFAULT} + read -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT + DNS_SERVER_PORT=${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT} + if [[ -z "$DNS_SERVER" ]]; then + echo -e "${RED}DNS server is required in udp mode${NC}" + exit 1 + fi + if ! [[ "$DNS_SERVER_PORT" =~ ^[0-9]+$ ]] || [[ "$DNS_SERVER_PORT" -lt 1 ]] || [[ "$DNS_SERVER_PORT" -gt 65535 ]]; then + echo -e "${RED}DNS server port must be an integer between 1 and 65535${NC}" + exit 1 + fi + DNS_SERVER_JSON=$(cat < "$CONFIG_BASE_FILE" < "$CONFIG_OUTBOUNDS_FILE" < "$SERVICE_FILE" < Date: Wed, 15 Apr 2026 21:32:06 +0800 Subject: [PATCH 95/97] =?UTF-8?q?=E5=85=88=E5=8D=B8=E8=BD=BDV2BX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 7521c045..cb79c4a4 100644 --- a/install.sh +++ b/install.sh @@ -18,6 +18,9 @@ SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" LEGACY_SERVICE_NAMES=("ganclient" "sing-box") RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://s3.cloudyun.top/downloads/singbox}" PUBLISHED_SCRIPT_URL="${PUBLISHED_SCRIPT_URL:-https://s3.cloudyun.top/downloads/singbox/install.sh}" +V2BX_DETECTED=0 +V2BX_CONFIG_PATH="" +UNINSTALL_V2BX_DEFAULT="${UNINSTALL_V2BX_DEFAULT:-n}" echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}" echo -e "${YELLOW}Published install script: ${PUBLISHED_SCRIPT_URL}${NC}" @@ -48,9 +51,138 @@ DOWNLOAD_TARGET="${DOWNLOAD_TARGET:-linux-${BINARY_ARCH}}" DOWNLOAD_URL="${DOWNLOAD_URL:-${RELEASE_BASE_URL}/sing-box-${DOWNLOAD_TARGET}}" TMP_BINARY="$(mktemp)" -mkdir -p "$CONFIG_DIR" -mkdir -p "$CONFIG_MERGE_DIR" -mkdir -p "$WORK_DIR" +find_v2bx_config() { + local candidate + for candidate in \ + "/etc/V2bX/config.json" \ + "/usr/local/V2bX/config.json" \ + "/etc/v2bx/config.json" \ + "/usr/local/etc/V2bX/config.json" + do + if [[ -f "$candidate" ]]; then + V2BX_CONFIG_PATH="$candidate" + return 0 + fi + done + return 1 +} + +detect_v2bx() { + if command -v v2bx >/dev/null 2>&1; then + V2BX_DETECTED=1 + fi + if find_v2bx_config; then + V2BX_DETECTED=1 + fi + return 0 +} + +load_v2bx_defaults() { + if [[ -z "$V2BX_CONFIG_PATH" ]] && ! find_v2bx_config; then + return 1 + fi + + echo -e "${YELLOW}Detected V2bX configuration: ${V2BX_CONFIG_PATH}${NC}" + + if command -v python3 >/dev/null 2>&1; then + local parsed + parsed="$(python3 - "$V2BX_CONFIG_PATH" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +nodes = data.get("Nodes") or [] +node = nodes[0] if nodes else {} + +for key in ("ApiHost", "ApiKey", "NodeID"): + value = node.get(key, "") + if value is None: + value = "" + print(str(value)) +PY +)" + if [[ -n "$parsed" ]]; then + local parsed_api_host parsed_api_key parsed_node_id + parsed_api_host="$(printf '%s\n' "$parsed" | sed -n '1p')" + parsed_api_key="$(printf '%s\n' "$parsed" | sed -n '2p')" + parsed_node_id="$(printf '%s\n' "$parsed" | sed -n '3p')" + + if [[ -z "${PANEL_URL:-}" && -n "$parsed_api_host" ]]; then + PANEL_URL="$parsed_api_host" + fi + if [[ -z "${PANEL_TOKEN:-}" && -n "$parsed_api_key" ]]; then + PANEL_TOKEN="$parsed_api_key" + fi + if [[ -z "${NODE_ID:-}" && -n "$parsed_node_id" ]]; then + NODE_ID="$parsed_node_id" + fi + + echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${parsed_api_host:-}, NodeID=${parsed_node_id:-}${NC}" + fi + elif command -v jq >/dev/null 2>&1; then + local parsed + parsed="$(jq -r '(.Nodes[0].ApiHost // ""), (.Nodes[0].ApiKey // ""), (.Nodes[0].NodeID // "")' "$V2BX_CONFIG_PATH" 2>/dev/null || true)" + if [[ -n "$parsed" ]]; then + local parsed_api_host parsed_api_key parsed_node_id + parsed_api_host="$(printf '%s\n' "$parsed" | sed -n '1p')" + parsed_api_key="$(printf '%s\n' "$parsed" | sed -n '2p')" + parsed_node_id="$(printf '%s\n' "$parsed" | sed -n '3p')" + + if [[ -z "${PANEL_URL:-}" && -n "$parsed_api_host" ]]; then + PANEL_URL="$parsed_api_host" + fi + if [[ -z "${PANEL_TOKEN:-}" && -n "$parsed_api_key" ]]; then + PANEL_TOKEN="$parsed_api_key" + fi + if [[ -z "${NODE_ID:-}" && -n "$parsed_node_id" ]]; then + NODE_ID="$parsed_node_id" + fi + + echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${parsed_api_host:-}, NodeID=${parsed_node_id:-}${NC}" + fi + else + echo -e "${YELLOW}Neither python3 nor jq found, skipping automatic V2bX config import.${NC}" + fi + + return 0 +} + +stop_v2bx_if_present() { + if [[ "$V2BX_DETECTED" -ne 1 ]]; then + return 0 + fi + if ! command -v v2bx >/dev/null 2>&1; then + echo -e "${YELLOW}V2bX config detected but 'v2bx' command not found, skipping stop/disable.${NC}" + return 0 + fi + + echo -e "${YELLOW}Detected V2bX, stopping and disabling it before continuing...${NC}" + v2bx stop || true + v2bx disable || true +} + +prompt_uninstall_v2bx() { + if [[ "$V2BX_DETECTED" -ne 1 ]]; then + return 0 + fi + if ! command -v v2bx >/dev/null 2>&1; then + return 0 + fi + + read -p "Detected V2bX. Uninstall it now? [${UNINSTALL_V2BX_DEFAULT}]: " INPUT_UNINSTALL_V2BX + local uninstall_v2bx_answer + uninstall_v2bx_answer=${INPUT_UNINSTALL_V2BX:-$UNINSTALL_V2BX_DEFAULT} + + if [[ "$uninstall_v2bx_answer" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then + echo -e "${YELLOW}Running: v2bx uninstall${NC}" + v2bx uninstall + else + echo -e "${YELLOW}Keeping existing V2bX installation.${NC}" + fi +} download_binary() { echo -e "${YELLOW}Downloading sing-box release binary...${NC}" @@ -106,14 +238,23 @@ cleanup_legacy_service() { done } -download_binary -cleanup_legacy_service +detect_v2bx +stop_v2bx_if_present + +mkdir -p "$CONFIG_DIR" +mkdir -p "$CONFIG_MERGE_DIR" +mkdir -p "$WORK_DIR" if [[ -f ".env" ]]; then echo -e "${YELLOW}Loading configuration from .env...${NC}" source .env fi +load_v2bx_defaults || true + +download_binary +cleanup_legacy_service + read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL PANEL_URL=${INPUT_URL:-$PANEL_URL} @@ -330,6 +471,8 @@ systemctl daemon-reload systemctl enable "$SERVICE_NAME" systemctl restart "$SERVICE_NAME" +prompt_uninstall_v2bx + echo -e "${GREEN}Service installed and started successfully.${NC}" echo -e "${GREEN}Check status with: systemctl status ${SERVICE_NAME}${NC}" echo -e "${GREEN}View logs with: journalctl -u ${SERVICE_NAME} -f${NC}" From 0df93ed044144bfbfa71561b5b40e05c3cb0838d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 21:55:45 +0800 Subject: [PATCH 96/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 204 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 149 insertions(+), 55 deletions(-) diff --git a/install.sh b/install.sh index cb79c4a4..16f2d843 100644 --- a/install.sh +++ b/install.sh @@ -21,8 +21,11 @@ PUBLISHED_SCRIPT_URL="${PUBLISHED_SCRIPT_URL:-https://s3.cloudyun.top/downloads/ V2BX_DETECTED=0 V2BX_CONFIG_PATH="" UNINSTALL_V2BX_DEFAULT="${UNINSTALL_V2BX_DEFAULT:-n}" +SCRIPT_VERSION="${SCRIPT_VERSION:-v1.2.4}" +declare -a V2BX_IMPORTED_NODE_IDS=() echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}" +echo -e "${GREEN}Script version: ${SCRIPT_VERSION}${NC}" echo -e "${YELLOW}Published install script: ${PUBLISHED_SCRIPT_URL}${NC}" if [[ $EUID -ne 0 ]]; then @@ -36,6 +39,17 @@ if [[ "$OS" != "Linux" ]]; then exit 1 fi +if [[ ! -t 0 ]]; then + if [[ -r /dev/tty ]]; then + exec 3/dev/null 2>&1; then local parsed @@ -97,56 +156,76 @@ with open(path, "r", encoding="utf-8") as f: nodes = data.get("Nodes") or [] node = nodes[0] if nodes else {} -for key in ("ApiHost", "ApiKey", "NodeID"): - value = node.get(key, "") - if value is None: - value = "" - print(str(value)) +api_host = node.get("ApiHost", "") if node else "" +api_key = node.get("ApiKey", "") if node else "" +print(f"API_HOST={api_host or ''}") +print(f"API_KEY={api_key or ''}") +for entry in nodes: + node_id = entry.get("NodeID", "") + if node_id is None: + node_id = "" + print(f"NODE_ID={node_id}") PY )" if [[ -n "$parsed" ]]; then - local parsed_api_host parsed_api_key parsed_node_id - parsed_api_host="$(printf '%s\n' "$parsed" | sed -n '1p')" - parsed_api_key="$(printf '%s\n' "$parsed" | sed -n '2p')" - parsed_node_id="$(printf '%s\n' "$parsed" | sed -n '3p')" - - if [[ -z "${PANEL_URL:-}" && -n "$parsed_api_host" ]]; then - PANEL_URL="$parsed_api_host" - fi - if [[ -z "${PANEL_TOKEN:-}" && -n "$parsed_api_key" ]]; then - PANEL_TOKEN="$parsed_api_key" - fi - if [[ -z "${NODE_ID:-}" && -n "$parsed_node_id" ]]; then - NODE_ID="$parsed_node_id" - fi - - echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${parsed_api_host:-}, NodeID=${parsed_node_id:-}${NC}" + local parsed_line + while IFS= read -r parsed_line; do + parsed_line="$(sanitize_value "$parsed_line")" + case "$parsed_line" in + API_HOST=*) + if [[ -z "${PANEL_URL:-}" ]]; then + PANEL_URL="$(sanitize_value "${parsed_line#API_HOST=}")" + fi + ;; + API_KEY=*) + if [[ -z "${PANEL_TOKEN:-}" ]]; then + PANEL_TOKEN="$(sanitize_value "${parsed_line#API_KEY=}")" + fi + ;; + NODE_ID=*) + append_unique_node_id "${parsed_line#NODE_ID=}" + ;; + esac + done <<< "$parsed" fi elif command -v jq >/dev/null 2>&1; then local parsed - parsed="$(jq -r '(.Nodes[0].ApiHost // ""), (.Nodes[0].ApiKey // ""), (.Nodes[0].NodeID // "")' "$V2BX_CONFIG_PATH" 2>/dev/null || true)" + parsed="$(jq -r '(.Nodes[0].ApiHost // "" | "API_HOST=" + .), (.Nodes[0].ApiKey // "" | "API_KEY=" + .), (.Nodes[]?.NodeID // "" | tostring | "NODE_ID=" + .)' "$V2BX_CONFIG_PATH" 2>/dev/null || true)" if [[ -n "$parsed" ]]; then - local parsed_api_host parsed_api_key parsed_node_id - parsed_api_host="$(printf '%s\n' "$parsed" | sed -n '1p')" - parsed_api_key="$(printf '%s\n' "$parsed" | sed -n '2p')" - parsed_node_id="$(printf '%s\n' "$parsed" | sed -n '3p')" - - if [[ -z "${PANEL_URL:-}" && -n "$parsed_api_host" ]]; then - PANEL_URL="$parsed_api_host" - fi - if [[ -z "${PANEL_TOKEN:-}" && -n "$parsed_api_key" ]]; then - PANEL_TOKEN="$parsed_api_key" - fi - if [[ -z "${NODE_ID:-}" && -n "$parsed_node_id" ]]; then - NODE_ID="$parsed_node_id" - fi - - echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${parsed_api_host:-}, NodeID=${parsed_node_id:-}${NC}" + local parsed_line + while IFS= read -r parsed_line; do + parsed_line="$(sanitize_value "$parsed_line")" + case "$parsed_line" in + API_HOST=*) + if [[ -z "${PANEL_URL:-}" ]]; then + PANEL_URL="$(sanitize_value "${parsed_line#API_HOST=}")" + fi + ;; + API_KEY=*) + if [[ -z "${PANEL_TOKEN:-}" ]]; then + PANEL_TOKEN="$(sanitize_value "${parsed_line#API_KEY=}")" + fi + ;; + NODE_ID=*) + append_unique_node_id "${parsed_line#NODE_ID=}" + ;; + esac + done <<< "$parsed" fi else echo -e "${YELLOW}Neither python3 nor jq found, skipping automatic V2bX config import.${NC}" fi + if [[ -z "${NODE_ID:-}" && "${#V2BX_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then + NODE_ID="${V2BX_IMPORTED_NODE_IDS[0]}" + fi + + if [[ "${#V2BX_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then + echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-}, NodeIDs=$(IFS=,; echo "${V2BX_IMPORTED_NODE_IDS[*]}")${NC}" + else + echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-}, NodeIDs=${NC}" + fi + return 0 } @@ -172,7 +251,7 @@ prompt_uninstall_v2bx() { return 0 fi - read -p "Detected V2bX. Uninstall it now? [${UNINSTALL_V2BX_DEFAULT}]: " INPUT_UNINSTALL_V2BX + read -u 3 -p "Detected V2bX. Uninstall it now? [${UNINSTALL_V2BX_DEFAULT}]: " INPUT_UNINSTALL_V2BX local uninstall_v2bx_answer uninstall_v2bx_answer=${INPUT_UNINSTALL_V2BX:-$UNINSTALL_V2BX_DEFAULT} @@ -252,32 +331,39 @@ fi load_v2bx_defaults || true +PANEL_URL="$(sanitize_value "${PANEL_URL:-}")" +PANEL_TOKEN="$(sanitize_value "${PANEL_TOKEN:-}")" +NODE_ID="$(sanitize_value "${NODE_ID:-}")" +ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${ENABLE_PROXY_PROTOCOL_HINT:-n}")" + download_binary cleanup_legacy_service -read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL -PANEL_URL=${INPUT_URL:-$PANEL_URL} +read -u 3 -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL +PANEL_URL="$(sanitize_value "${INPUT_URL:-$PANEL_URL}")" -read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN -PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} +read -u 3 -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN +PANEL_TOKEN="$(sanitize_value "${INPUT_TOKEN:-$PANEL_TOKEN}")" -read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL -ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}} +read -u 3 -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL +ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}}")" declare -a NODE_IDS i=1 while true; do DEFAULT_NODE_ID="" - if [[ "$i" -eq 1 && -n "${NODE_ID:-}" ]]; then + if [[ "$i" -le "${#V2BX_IMPORTED_NODE_IDS[@]}" ]]; then + DEFAULT_NODE_ID="${V2BX_IMPORTED_NODE_IDS[$((i-1))]}" + elif [[ "$i" -eq 1 && -n "${NODE_ID:-}" ]]; then DEFAULT_NODE_ID="$NODE_ID" fi if [[ -n "$DEFAULT_NODE_ID" ]]; then - read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type NO to finish): " INPUT_ID + read -u 3 -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type NO to finish): " INPUT_ID else - read -p "Enter Node ID for node #$i (type NO to finish): " INPUT_ID + read -u 3 -p "Enter Node ID for node #$i (type NO to finish): " INPUT_ID fi - CURRENT_NODE_ID=${INPUT_ID:-$DEFAULT_NODE_ID} + CURRENT_NODE_ID="$(sanitize_value "${INPUT_ID:-$DEFAULT_NODE_ID}")" if [[ "$CURRENT_NODE_ID" =~ ^([nN][oO])$ ]]; then if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then echo -e "${RED}At least one Node ID is required${NC}" @@ -285,31 +371,39 @@ while true; do fi break fi + CURRENT_NODE_ID="$(normalize_node_id_input "$CURRENT_NODE_ID")" if [[ -z "$CURRENT_NODE_ID" ]]; then echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi - if ! [[ "$CURRENT_NODE_ID" =~ ^[0-9]+$ ]]; then - echo -e "${RED}Node ID must be a positive integer${NC}" + read -r -a CURRENT_NODE_ID_PARTS <<< "$CURRENT_NODE_ID" + if [[ "${#CURRENT_NODE_ID_PARTS[@]}" -eq 0 ]]; then + echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi - NODE_IDS+=("$CURRENT_NODE_ID") + for CURRENT_NODE_ID_PART in "${CURRENT_NODE_ID_PARTS[@]}"; do + if ! [[ "$CURRENT_NODE_ID_PART" =~ ^[0-9]+$ ]]; then + echo -e "${RED}Node ID must be a positive integer, got: ${CURRENT_NODE_ID_PART}${NC}" + exit 1 + fi + NODE_IDS+=("$CURRENT_NODE_ID_PART") + done ((i++)) done NODE_COUNT=${#NODE_IDS[@]} DNS_MODE_DEFAULT=${DNS_MODE:-udp} -read -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE +read -u 3 -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE DNS_MODE=$(echo "${INPUT_DNS_MODE:-$DNS_MODE_DEFAULT}" | tr '[:upper:]' '[:lower:]') case "$DNS_MODE" in udp) DNS_SERVER_DEFAULT=${DNS_SERVER:-1.1.1.1} DNS_SERVER_PORT_DEFAULT=${DNS_SERVER_PORT:-53} - read -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER + read -u 3 -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER DNS_SERVER=${INPUT_DNS_SERVER:-$DNS_SERVER_DEFAULT} - read -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT + read -u 3 -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT DNS_SERVER_PORT=${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT} if [[ -z "$DNS_SERVER" ]]; then echo -e "${RED}DNS server is required in udp mode${NC}" From 989f6244d51b91a7e66cb8891481a8c3e0d5b0a3 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 22:01:03 +0800 Subject: [PATCH 97/97] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 16f2d843..984a195d 100644 --- a/install.sh +++ b/install.sh @@ -359,12 +359,15 @@ while true; do DEFAULT_NODE_ID="$NODE_ID" fi if [[ -n "$DEFAULT_NODE_ID" ]]; then - read -u 3 -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type NO to finish): " INPUT_ID + read -u 3 -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type N/NO to finish): " INPUT_ID else - read -u 3 -p "Enter Node ID for node #$i (type NO to finish): " INPUT_ID + read -u 3 -p "Enter Node ID for node #$i (press Enter or type N/NO to finish): " INPUT_ID fi CURRENT_NODE_ID="$(sanitize_value "${INPUT_ID:-$DEFAULT_NODE_ID}")" - if [[ "$CURRENT_NODE_ID" =~ ^([nN][oO])$ ]]; then + if [[ -z "$DEFAULT_NODE_ID" && -z "$CURRENT_NODE_ID" && "${#NODE_IDS[@]}" -gt 0 ]]; then + break + fi + if [[ "$CURRENT_NODE_ID" =~ ^([nN]|[nN][oO])$ ]]; then if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then echo -e "${RED}At least one Node ID is required${NC}" exit 1