Skip to content

relux-works/multi-tun

Repository files navigation

multi-tun

multi-tun currently hosts local CLIs for:

  • vless-tun for DenseVPN / DanceVPN over sing-box, with optional Xray-core sidecar support for VLESS profiles that need Xray-only fields
  • openconnect-tun for Cisco AnyConnect / ASA profile inspection and future OpenConnect runtime work
  • dump for manual VPN capture sessions and local dump artifacts

The vless-tun flow replaces the v2RayTun client path with a controllable desktop workflow for DenseVPN / DanceVPN subscriptions. sing-box remains the default runtime and TUN frontend; selected profiles can opt into an Xray-core sidecar when the provider requires VLESS features that sing-box does not parse.

Current scope:

  • fetch a live subscription URL such as https://key.vpn.dance/connect?...
  • decode plaintext or base64 subscription payloads
  • parse vless:// profiles, including Reality + gRPC variants
  • cache the latest subscription snapshot locally
  • render a sing-box TUN config with optional suffix-based direct bypasses, or an Xray sidecar config plus sing-box TUN frontend for Xray-selected profiles

This repo also keeps live notes from previous VPN investigations:

  • v2raytun-dancevpn-routing.md
  • corp-vpn-wifi-bypass.md

Release notes and migration guidance live in RELEASE_NOTES.md.

Operational instructions live under instructions/:

Platform Layout

  • desktop/: current shipped codebase
    • desktop/internal/core/: shared desktop infrastructure
    • desktop/internal/vless/: VLESS-specific desktop logic
    • desktop/internal/anyconnect/: Cisco/OpenConnect-specific desktop logic
  • android/: Android client/runtime workspace with a modular app shell, persisted generic tunnel config, real VpnService + libbox TUN runtime, and a separate observer app for cross-UID egress checks
  • ios/: future iOS client/runtime workspace

Android real-device smoke currently lives behind the dedicated runner:

./scripts/android/run-device-smoke.sh
./scripts/android/run-device-smoke.sh --serial 535a1632
./scripts/android/run-device-smoke.sh \
  --serial 535a1632 \
  --test-class works.relux.vless_tun_app.TunnelHomeEditorStateTest
./scripts/android/run-device-smoke.sh \
  --serial 535a1632 \
  --test-class works.relux.vless_tun_app.TunnelEgressSmokeTest \
  --source-inline-vless-from-desktop-config
./scripts/android/run-device-suite.sh \
  --serial 535a1632 \
  --source-inline-vless-from-desktop-config

That path uses preinstall + direct adb shell am instrument instead of connectedDebugAndroidTest, which is more reliable on MIUI/Xiaomi devices. The stable split is:

  • TunnelHomeSmokeTest#tunnelHomeLoads for cold app launch via UiAutomator
  • TunnelHomeEditorStateTest for source-url editor/save regression via Compose instrumentation
  • TunnelConnectSmokeTest for VpnService/TUN bring-up
  • TunnelEgressSmokeTest for real cross-UID egress change through the observer app

The live egress loop is verified on a real Xiaomi device with a separate observer app: direct observer egress was 91.77.167.22 · Russia (RU), and after connect it switched to 144.31.90.46 · Finland (FI).

Quick Start

./scripts/setup.sh
./scripts/setup.sh --mac-arch amd64
./scripts/deinit.sh --dry-run

# edit ~/.config/vless-tun/config.json and set servers.<name>.source.url

vless-tun refresh dance
vless-tun list dance
vless-tun setup --source-url "vless://..."
vless-tun set-current fortinetz de
vless-tun start dance
vless-tun start fortinetz nl
vless-tun reconnect dance
vless-tun status dance
vless-tun diagnose
vless-tun stop
vless-tun render dance
vpn-core inspect-vless-url --insecure "https://example.com/subscription"
openconnect-tun status
openconnect-tun setup --vpn-name "Corp VPN"
openconnect-tun profiles
openconnect-tun inspect-profiles
openconnect-tun set-current ural ural-outside
openconnect-tun start msk msk-outside --mode full --dry-run
openconnect-tun start ural ural-outside --mode split-include --dry-run
dump status

./scripts/setup.sh installs the shipped desktop toolchain end-to-end: it ensures the default runtime prerequisites such as sing-box, builds the bundled desktop/cmd/vpn-auth Swift helper, and links the resulting binaries into ~/.local/bin. xray is an optional runtime prerequisite only for configs that set engine.type=xray.

That installed toolchain now also includes android-release, the local Go helper for:

  • seeding Android release-signing metadata
  • generating the Play upload keystore
  • building a signed app-release.aab for Play test tracks with required release notes sidecar output
  • emitting native-debug-symbols.zip and ProGuard mapping artifacts for Play symbolication
  • manually publishing the signed bundle to a chosen Google Play track via Gradle Play Publisher

On macOS the default ./scripts/setup.sh path is host-native: on Apple Silicon it builds/install arm64 binaries, and on Intel Macs it builds/install amd64 binaries with the normal toolchain prerequisites for that machine. If you explicitly pass --mac-arch arm64|amd64 for the non-host architecture, setup switches into artifact-only cross-build mode and writes desktop binaries into artifacts/releases/ without touching ~/.local/bin, configs, or skill wiring.

./scripts/deinit.sh removes the managed multi-tun global/local skill links and ~/.local/bin symlinks. Config, cache, keychain secrets, and repo build artifacts stay intact unless you pass the explicit --purge-* flags.

Generated artifacts:

  • cache snapshot: servers.<name>.cache_dir/snapshot.json
  • rendered sing-box config: servers.<name>.artifacts.singbox_config_path
  • rendered Xray sidecar config when engine.type=xray: servers.<name>.artifacts.xray_config_path, or a derived xray_*.json next to the sing-box config

Tools

Tool Used for How to run Outputs
task-board File-based task tracking for repo work task-board q --format compact 'summary()', task-board m 'set_notes(TASK-ID, text="...")' .task-board/
vless-tun VLESS subscription refresh, profile rendering, and local TUN sessions vless-tun refresh dance, vless-tun render fortinetz nl ~/.config/vless-tun/, ~/.cache/vless-tun/, rendered runtime JSON
openconnect-tun Cisco/OpenConnect profile inspection and runtime experiments openconnect-tun status, openconnect-tun inspect-profiles ~/.config/openconnect-tun/, ~/.cache/openconnect-tun/
vpn-core Privileged helper backend for tunnel startup and packet capture support; also includes a user-space VLESS subscription metadata probe vpn-core install, vpn-core status, vpn-core inspect-vless-url --insecure <url> LaunchDaemon state, helper logs under runtime cache paths
sing-box VLESS/Reality client runtime and config validation sing-box check -c <config.json> Runtime logs in session cache or .temp/
xray Optional VLESS engine sidecar for profiles that require Xray-only VLESS fields such as encryption=mlkem... xray run -c <xray-config.json> through vless-tun start when engine.type=xray Sidecar config and sidecar logs under the selected vless-tun cache/artifact paths
Docker / Colima Linux container labs and controlled SNI-gate experiments artifacts/fortinetz-cascade-whitelist/lab/run-macmini-sni-lab.sh nl .temp/fortinetz-cascade/sni-lab-remote/; remote scratch under /Users/administrator/.cache/multi-tun/fortinetz-sni-lab
ssh / rsync Remote Mac mini lab orchestration without disturbing the local tunnel ssh relux-works-dedicated-macmini ..., used by the lab runner .temp/fortinetz-cascade/remote-*.log
go Desktop CLI builds and tests go test ./..., go build -o vless-tun ./desktop/cmd/vless-tun local binaries, test logs in .temp/ when captured
xcodebuild / swift Apple helper builds and tests when needed run through ./scripts/setup.sh or targeted Swift build commands build products under project and toolchain output paths
adb / Gradle Android smoke and instrumentation tests ./scripts/android/run-device-smoke.sh --serial <serial> Android build outputs and smoke logs under script-defined paths

Commands

vless-tun setup
vless-tun init
vless-tun refresh dance
vless-tun list dance
vless-tun set-current fortinetz de
vless-tun start dance
vless-tun start fortinetz nl
vless-tun reconnect dance
vless-tun status dance
vless-tun stop
vless-tun render dance

openconnect-tun

Use this companion CLI when you need to inspect Cisco AnyConnect / ASA state, resolve real ASA profile targets, and stage OpenConnect routing experiments next to vless-tun.

The corporate hostnames, domains, profile labels, and IPs shown in the examples below are anonymized placeholders.

vpn-core status
vpn-core install
openconnect-tun status
openconnect-tun helper status
openconnect-tun helper install
openconnect-tun profiles
openconnect-tun inspect-profiles
openconnect-tun inspect-profiles --dir ~/Downloads/cisco-anyconnect-profiles/profiles
openconnect-tun setup --vpn-name 'Corp VPN'
openconnect-tun set-current ural ural-outside
openconnect-tun start msk msk-outside --mode full --dry-run
openconnect-tun start ural ural-outside --mode split-include --dry-run
openconnect-tun start --profile 'Ural Outside extended' --mode full --dry-run
openconnect-tun start --profile 'Ural Outside extended' --mode split-include \
  --second-factor-mode manual_otp \
  --route 198.51.100.0/24 \
  --route 203.0.113.0/24 \
  --vpn-domains corp.example,digital.example,services.corp.example \
  --bypass-suffixes bypass.corp.example \
  --dry-run
openconnect-tun reconnect --profile 'Ural Outside extended' --mode full
openconnect-tun routes
openconnect-tun stop

dump start
dump start --probe-host gitlab.services.corp.example --probe-host portal.corp.example
dump status
dump stop
dump inspect --session-id 20260326T144914Z

Operational notes:

  • openconnect-tun keeps its own runtime state under ~/.cache/openconnect-tun, with session logs in ~/.cache/openconnect-tun/sessions and the current session pointer in ~/.cache/openconnect-tun/runtime/current-session.json. This is intentionally separate from ~/.cache/vless-tun.
  • vpn-core install performs the one-time privileged setup for autonomous runs. It installs a shared root LaunchDaemon that exposes a user-owned unix socket, so later openconnect-tun and vless-tun commands can reuse the same trusted backend without repeated sudo.
  • on macOS vless-tun start in network.mode=tun now refuses to start if the upstream VLESS server itself already routes through another VPN interface such as utun*, tun*, ppp*, or ipsec*. That avoids accidental nested-tunnel startup where vless-tun silently builds on top of v2RayTun, AnyConnect, or another active VPN and drags the whole network into an undefined state.
  • Existing installs of the legacy works.relux.openconnect-tun-helper daemon are auto-detected for compatibility. A fresh vpn-core install replaces that legacy helper with the shared core service.
  • openconnect-tun helper install|status|uninstall remain as compatibility wrappers around the shared vpn-core service.
  • --profile resolves against local AnyConnect XML HostEntry values and automatically deduplicates the same server repeated across /opt/cisco/... and ~/Downloads/....
  • the canonical lifecycle commands are now start, reconnect, status, and stop; run, connect, and disconnect remain as compatibility aliases.
  • openconnect-tun set-current [server [profile]] updates default.server_url and default.profile in ~/.config/openconnect-tun/config.json. If the selected configured server has the current default profile or exactly one configured profile, the profile argument may be omitted; otherwise pass it explicitly.
  • openconnect-tun start [server [profile]] and openconnect-tun reconnect [server [profile]] can override the default configured server/profile for one run. Existing --server / --profile flags remain supported.
  • configured OpenConnect server keys can be friendly aliases. Put the real ASA endpoint in servers.<alias>.server_url; configured profile keys can also be aliases, with the real AnyConnect profile label in servers.<alias>.profiles.<profile_alias>.name.
  • openconnect-tun can read auth defaults from ~/.config/openconnect-tun/config.json. The preferred shape is servers.<url>.auth, so credentials are selected from the server being used. The legacy root-level auth block still works as a compatibility fallback. The shipped bootstrap convention is fully keychain-backed: servers.<url>.auth.username_keychain_account=corp-vpn/username and servers.<url>.auth.password_keychain_account=corp-vpn/password. Plain username remains as a compatibility fallback. servers.<url>.auth.second_factor.mode controls whether the OTP step waits for manual SMS/manual entry or auto-submits TOTP.
  • live auth now defaults to --auth aggregate, which is the only path that currently completes the example SSO+CSD flow on this machine; --auth openconnect remains available as the direct openconnect --authenticate path for debugging. vpn-auth is used for the browser-assisted SAML steps in aggregate mode, with preset-cookie support for follow-up pages.
  • ./scripts/setup.sh now treats the full live runtime as part of the shipped toolchain: it ensures sing-box for vless-tun, totp-cli for vpn-auth, builds desktop/cmd/vpn-auth, installs the resulting binary into ~/.local/bin, and ./scripts/deinit.sh removes that managed binary link again.
  • the CSD helper is resolved from the active OpenConnect install, preferring native Cisco libcsd.dylib when it is available under ~/.cisco/...; otherwise it falls back to Homebrew's stable opt/openconnect/libexec/openconnect/csd-post.sh path instead of versioned Cellar/... paths. The fallback csd-post.sh path is still wrapped with tiny macOS shims for pidof and GNU-style stat -c %Y.
  • dump is now the canonical activity-oriented name for packet diagnostics. cisco-dump remains installed as a compatibility alias, while runtime state stays under ~/.cache/cisco-dump for continuity.
  • dump keeps its own runtime state under ~/.cache/cisco-dump, with session logs in ~/.cache/cisco-dump/sessions, the current session pointer in ~/.cache/cisco-dump/runtime/current-session.json, mirrored Cisco logs and cache artifacts under each session directory, tracked Cisco per-pid lsof snapshots, all-loopback TCP snapshots from lsof/netstat, a default tunnel-aware traffic capture pcap, a separate loopback OCSC pcap, and host-level DNS/route/TCP/HTTPS probe snapshots for the anonymized example targets.
  • dump start now defaults to pktap,all with a broad tcp or udp filter so tunnel traffic on utun*, loopback IPC, and ordinary uplink traffic are captured in one session. A separate localhost-loopback.pcap sidecar is still recorded so OCSC decoding remains stable.
  • when a dump session stops with an OCSC loopback pcap present, it now also derives ocsc-timeline.txt and ocsc-summary.txt from AnyConnect OCSC loopback traffic. dump inspect --session-id ... can re-run that decoder on an older captured session.
  • dump start now defaults to probing gitlab.services.corp.example and portal.corp.example; pass --probe-host, --probe-ns, or --no-host-probes when you need a different target set.
  • live dump tcpdump capture now prefers the shared vpn-core backend when it is installed, and falls back to sudo otherwise. The rest of the diagnostic session stays in user space.
  • live start now emits compact auth_stage: updates in the terminal during authentication instead of forcing you to tail the session log for every auth transition. The full detailed auth log still stays in the session log file.
  • live start now resolves the privileged backend automatically: shared vpn-core first when it is installed, otherwise the old sudo -v + sudo -n path so the privileged password prompt still does not compete with the cookie being piped into --cookie-on-stdin.
  • --mode full uses the stock vpnc-script, so OpenConnect will own default route and global DNS. Treat it as a smoke-test path, not a coexistence-safe mode.
  • --mode split-include uses vpn-slice. On macOS that means scoped /etc/resolver/<domain> files for VPN DNS instead of replacing the global resolver stack, which is the direction we want for split-include coexistence next to vless-tun.
  • host trust and guest trust are separate concerns. A macOS host may trust a corporate CA in Keychain while a Colima or Docker VM still rejects the same internal registry with x509: certificate signed by unknown authority. If an internal registry is reachable from the host but docker pull fails inside the guest, install the corporate CA into the guest trust store or into /etc/docker/certs.d/<registry>/ca.crt on the daemon host.

openconnect-tun setup

Scaffolds a default openconnect-tun config and the matching keychain account names for one VPN profile.

Pass --vpn-name with the user-facing AnyConnect profile name. setup resolves the matching server_url from local AnyConnect XML, writes a default full-mode config with no bypasses, seeds placeholder keychain entries for username/password/TOTP, and prints the resulting config path so the caller can review it.

If TOTP starts from a Google Authenticator export QR, use ./scripts/google-auth-export-secret.sh. The export URL contains a URL-encoded base64 protobuf payload, but the final Keychain value for totp_secret must be the derived base32 secret:

./scripts/google-auth-export-secret.sh 'otpauth-migration://offline?...'
./scripts/google-auth-export-secret.sh --list 'otpauth-migration://offline?...'

openconnect-tun configuration

Default live config path:

  • ~/.config/openconnect-tun/config.json

Example config:

{
  "cache_dir": "~/.cache/openconnect-tun",
  "default": {
    "server_url": "vpn-gw2.corp.example/outside",
    "profile": "ural-outside"
  },
  "servers": {
    "ural": {
      "server_url": "vpn-gw2.corp.example/outside",
      "auth": {
        "username_keychain_account": "corp-vpn/username",
        "password_keychain_account": "corp-vpn/password",
        "second_factor": {
          "mode": "manual_otp",
          "totp_secret_keychain_account": "corp-vpn/totp_secret"
        },
        "fallback_servers": [
          "vpn-gw2-backup.corp.example/outside"
        ]
      },
      "client_mimicry": {
        "user_agent": "AnyConnect",
        "version": "4.10.08029",
        "os": "mac-intel",
        "local_hostname": "corp-macbook",
        "auth_methods": [
          "single-sign-on-v2",
          "single-sign-on-external-browser"
        ],
        "http_headers": {
          "X-Support-HTTP-Auth": "true"
        }
      },
      "profiles": {
        "ural-outside": {
          "name": "Ural Outside extended",
          "mode": "split-include",
          "split_include": {
            "routes": [
              "10.0.0.0/8",
              "11.0.0.0/8",
              "172.16.0.0/12",
              "192.168.0.0/16"
            ],
            "nameservers": [
              "10.23.16.4",
              "10.23.0.23"
            ],
            "vpn_domains": [
              "corp.example",
              "corp-it.example",
              "corp-it.internal",
              "erp.example",
              "short.example",
              "corp-sec.example",
              "branch.example"
            ],
            "bypass_suffixes": [
              "vpn-gw2.corp.example",
              "bypass.corp.example"
            ]
          }
        },
        "public": {
          "name": "Public Corp",
          "mode": "full"
        }
      }
    }
  }
}

Field reference:

  • cache_dir: local runtime/state directory for openconnect-tun. This is where the CLI keeps its own ephemeral artifacts, not user-authored config.
  • cache_dir session logs: ~/.cache/openconnect-tun/sessions/openconnect-session-<UTC timestamp>.log
  • cache_dir runtime metadata: ~/.cache/openconnect-tun/runtime/current-session.json
  • cache_dir helper/runtime logs: files under ~/.cache/openconnect-tun/runtime/, such as orphan-cleanup logs
  • cache_dir separation: intentionally separate from ~/.cache/vless-tun, so the two tunnel stacks do not overwrite each other's runtime state
  • default.server_url: the default OpenConnect target when start|reconnect runs without an explicit --server
  • default.profile: the default configured profile key when start|reconnect runs without an explicit --profile; it may be a friendly alias such as ural-outside
  • default pairing: these two fields are meant to point at the same configured VPN choice, so the config reads as one default selection instead of separate root-level knobs
  • openconnect-tun set-current: validates the selected servers.<url> entry and profile, then rewrites the paired default block for later no-argument starts
  • servers.<alias>: configuration bucket for one ASA endpoint. The key may be a friendly alias such as msk or ural
  • servers.<alias>.server_url: the real ASA endpoint such as vpn-gw2.corp.example/outside; legacy configs may still use the URL itself as the servers key
  • servers.<alias>.auth: preferred auth override for that concrete server. Use this when different ASA endpoints require different keychain entries, usernames, or aggregate-auth fallback behavior
  • servers.<url>.auth.second_factor.mode: second-factor behavior for browser-assisted SAML auth. Valid values are manual_otp and totp_auto.
  • servers.<url>.auth.second_factor.mode=manual_otp: auto-fill username/password, then wait for an SMS or other manual one-time code on terminal stdin. vpn-auth injects that code into the OTP WebView and submits the form; if the page returns to OTP, enter the next code in the terminal.
  • servers.<url>.auth.second_factor.mode=totp_auto: read second_factor.totp_secret_keychain_account, generate TOTP, and auto-submit the OTP form. The legacy top-level totp_secret_keychain_account is still accepted as a compatibility shorthand for totp_auto.
  • servers.<url>.auth.second_factor.totp_secret_keychain_account: Keychain account containing the base32 TOTP secret. It can remain present while mode=manual_otp, so switching back to totp_auto does not require rediscovering the account name.
  • --second-factor-mode manual_otp|totp_auto: per-run override for start and reconnect, useful when the same ASA/SAML flow oscillates between SMS and authenticator TOTP.
  • servers.<url>.auth.fallback_servers: aggregate-auth fallback targets for that concrete server. If the initial endpoint or its redirect target returns an auth-request without sso-v2-login, the session log records the original request URL, final redirected URL, response summary, and configured fallback selected before retrying. Other request failures are not hidden by this fallback.
  • servers.<url>.client_mimicry: optional AnyConnect client identity for that concrete server. It is applied to aggregate-auth init/reply requests and to the final openconnect command, so endpoint-specific ASA/LB behavior can be reproduced without provider-specific code.
  • servers.<url>.client_mimicry.user_agent: User-Agent for aggregate-auth HTTP requests and openconnect --useragent. Defaults to AnyConnect when omitted.
  • servers.<url>.client_mimicry.version: client version for aggregate-auth XML <version> and openconnect --version-string. Defaults to the locally detected Cisco Secure Client version, then 4.10.07061.
  • servers.<url>.client_mimicry.os: OS value for openconnect --os; it is also used as aggregate-auth <device-id> when device_id is omitted.
  • servers.<url>.client_mimicry.device_id: explicit aggregate-auth XML <device-id> override when it should differ from os.
  • servers.<url>.client_mimicry.local_hostname: local hostname passed to openconnect --local-hostname; omitted values still use the host-detected name.
  • servers.<url>.client_mimicry.auth_methods: aggregate-auth <capabilities> auth methods. Omit the field to keep the Cisco-like default method list.
  • servers.<url>.client_mimicry.http_headers: extra or overriding aggregate-auth HTTP headers by name. Values are logged only by header name, not by value.
  • servers.<alias>.profiles.<profile_alias>: one profile variant under that server; this is where name, mode, and split_include live
  • servers.<alias>.profiles.<profile_alias>.name: the real AnyConnect profile label passed to OpenConnect, such as Ural Outside extended
  • legacy auth fallback: root-level auth is still accepted for older configs, but new configs should prefer servers.<url>.auth
  • servers.<url>.profiles.<profile>.mode: default connect mode for that profile. Use split-include for coexistence-safe split routing or full for stock full-tunnel behavior
  • servers.<url>.profiles.<profile>.split_include.routes: included CIDRs, hosts, or aliases passed to vpn-slice in split-include mode
  • servers.<url>.profiles.<profile>.split_include.nameservers: scoped VPN DNS servers to use for that profile
  • servers.<url>.profiles.<profile>.split_include.vpn_domains: suffix masks, not exact-only hostnames. corp.example already covers *.corp.example, so do not also list covered entries like inside.corp.example or region.corp.example
  • servers.<url>.profiles.<profile>.split_include.vpn_domains normalization: the CLI now collapses covered suffixes automatically during option resolution, but the config should still stay clean and minimal
  • servers.<url>.profiles.<profile>.split_include.bypass_suffixes: suffixes that must stay on the public resolver path even when a broader VPN suffix also matches
  • split_include.bypass_suffixes semantics: on macOS openconnect-tun implements bypasses by writing a more specific public /etc/resolver/<suffix> entry over the broader VPN-scoped resolver, so bypass.corp.example can stay public while corp.example still uses VPN DNS
  • split-DNS cleanup: when search.tailscale already contains search domains covered by split_include.vpn_domains, openconnect-tun removes both exact and covered subdomain entries from that search resolver while preserving anything covered by split_include.bypass_suffixes, so macOS does not bounce between public and VPN search paths
  • built-in example augmentation: for the anonymized /outside example targets openconnect-tun still augments split DNS with the extra official Cisco suffix set (inside.corp.example, region.corp.example, branch.example, workspace.example, and related domains), so the config can stay minimal without manually restating every covered subdomain

vless-tun setup

Creates ~/.config/vless-tun/config.json by default using the preferred schema.

Use --source-url for either an HTTP subscription endpoint or a literal vless://... URI. setup prints the resulting config path so the caller can review it immediately.

With no config argument, vless-tun uses ~/.config/vless-tun/config.json.

Lifecycle commands use positional arguments for selection overrides against that config:

vless-tun start [server [profile]]
vless-tun render [server [profile]]
vless-tun status [server [profile]]
vless-tun reconnect [server [profile]]
vless-tun set-current [server [profile]]
vless-tun list [server]
vless-tun refresh [server]
vless-tun stop
vless-tun diagnose
vless-tun diagnose config [server [profile]]

stop and diagnose do not require a provider/profile; they scan all configured session cache directories for recorded tunnel state. reconnect uses the same stop-all scan before starting the selected provider/profile, so switching current.server from one provider to another does not leave the old tunnel blocking the new one. Use diagnose config when you want to validate provider/profile selection.

Use --config when you intentionally want a non-default config file. setup and init still accept one positional config path for bootstrapping.

vless-tun set-current

Updates current.server and current.profile in ~/.config/vless-tun/config.json, so later no-argument commands use that selection:

vless-tun set-current dance
vless-tun set-current freedom
vless-tun set-current fortinetz de

If the selected server has a default profile or exactly one profile, the profile argument may be omitted. If the server has multiple profiles and no default, pass the profile explicitly.

vless-tun init

Creates ~/.config/vless-tun/config.json by default.

init remains as the compatibility entrypoint. --subscription-url remains the compatibility flag name, but the preferred config shape now writes it into source.url.

vless-tun refresh

Refreshes the configured VLESS source, parses all available vless:// profiles, and writes a local cache snapshot.

source.mode=proxy means source.url is fetched over HTTP and expected to resolve to one or more vless:// entries. source.mode=direct means source.url already contains a literal vless://... URI and no extra fetch indirection is used.

vless-tun list

Shows the cached profiles in a compact form. Use --refresh if you want it to pull the subscription first.

vless-tun start

Refreshes the selected provider subscription cache before rendering, including explicit profile aliases such as fortinetz nl, so provider-side endpoint, SNI, fingerprint, and profile changes are picked up at start time. Use --refresh=false only when the subscription endpoint is unavailable and the last cached snapshot is good enough to start from.

If the configured profile alias no longer matches any profile in the refreshed subscription, start fails before rendering or starting the tunnel, prints the available profiles, and points at the servers.<name>.profiles.<alias>.selector value to update.

Short provider starts work through positional selection overrides:

vless-tun start dance
vless-tun start fortinetz nl
vless-tun start fortinetz de
vless-tun start freedom

When vless-tun is running in network.mode=tun above an active openconnect-tun split-include session, start now waits for overlay DNS convergence before returning. In that overlay mode, a live sing-box PID alone is not treated as ready; the CLI also waits for the system public resolver path to settle so follow-up terminal clients do not race the DNS handoff. The public resolver handoff is applied through a scoped scutil resolver without copying MTS/corporate split domains into search suffixes; the active network service DNS is only a fallback if scoped handoff fails.

Each start creates a new timestamped session log and metadata pair under:

  • ~/.cache/vless-tun/sessions/sing-box-session-<UTC timestamp>.log
  • ~/.cache/vless-tun/sessions/session-<UTC timestamp>.json

With the default logging.max_lines=1000, helper-backed sessions retain only the last 1000 lines in the current sing-box log. This keeps accidental logging.level=info runs from creating multi-million-line files, but verbose engine logging can still cost CPU; use logging.level=warn for normal TUN runtime.

When engine.type=xray, vless-tun starts Xray as a local SOCKS sidecar first, then starts the sing-box TUN frontend. The sidecar gets its own log:

  • ~/.cache/vless-tun/sessions/xray-session-<UTC timestamp>.log

Before starting a sidecar, start scans for stale sidecar processes that match the configured sidecar executable/name and the same generated sidecar config path. Matching stale processes receive SIGTERM, with SIGKILL escalation if they do not exit. This cleanup is intentionally scoped to the generated config path; it does not broadly kill every xray process on the host.

The currently active session pointer is stored at:

  • ~/.cache/vless-tun/runtime/current-session.json

start is the command that should actually bring the tunnel up. status does not connect anything by itself.

In network.mode=tun, start resolves the launch backend automatically. If the shared vpn-core daemon is installed, that is the preferred happy-path backend. An explicit launch block is only needed as an override for fallback or debugging:

  • auto: resolve to shared vpn-core when it is installed, otherwise sudo for TUN as a regular user and direct when already root
  • sudo: run sudo sing-box run -c ... after caching credentials with sudo -v
  • direct: run sing-box directly; intended for already-root environments
  • helper: spawn and stop root sing-box through the shared vpn-core daemon
  • launchd: compatibility alias for helper; the long-lived LaunchDaemon now belongs to vpn-core, not to each sing-box session

vless-tun reconnect

Reloads the local config, stops any currently recorded session across all configured VLESS server cache directories, refreshes the subscription cache by default, rerenders the selected profile, and starts a fresh vless-tun session with the selected engine.

This is the command to use after changing:

  • current.server
  • current.profile
  • servers.<name>.profiles.<profile>.selector
  • routing.bypass_suffixes
  • routing.routes
  • dns.proxy_resolver
  • servers.<name>.artifacts.singbox_config_path
  • servers.<name>.artifacts.xray_config_path
  • engine
  • any other render-time setting in ~/.config/vless-tun/config.json

vless-tun status

Shows the current local view of the tunnel state:

  • whether a recorded vless-tun session is active, stale, or absent
  • the selected engine and any recorded sidecar processes
  • the current session ID, PID, launch mode, start timestamp, and log file path
  • whether the configured TUN interface exists
  • whether the rendered config file exists
  • which profile is selected from cache
  • which bypass suffixes are configured
  • which cached profiles are available

This is a heuristic runtime status, not a control plane.

vless-tun diagnose

vless-tun diagnose is a tunnel/runtime diagnostic. It scans configured session cache directories and reports recorded session state plus launch backend readiness. When the effective launch backend resolves to helper or launchd, it checks the shared vpn-core daemon and reports the current socket and daemon PID.

vless-tun diagnose config [server [profile]] is a config diagnostic. It validates selection, resolved cache/render paths, source mode, bypasses, and launch settings without starting or stopping a tunnel.

vless-tun stop

Stops the currently recorded vless-tun session using SIGTERM by default. For engine.type=xray, this also stops the Xray sidecar. Use --force if you want it to escalate to SIGKILL after the timeout.

vless-tun render

Selects a cached profile and writes runtime JSON config with:

  • a TUN inbound
  • proxy detour for the rest of the traffic
  • optional direct DNS and direct outbound for configured suffix bypasses
  • automatic direct routing and TUN route exclusion for IP-literal upstream VLESS endpoints, so the proxy server itself is not captured by broad full-TUN routes
  • default route sniffing, unless disabled through singbox.sniff
  • optional outbound TLS fragmentation, TLS record fragmentation, fallback delay, and TLS curve preferences from singbox.tls

If routing.bypass_suffixes is empty, the renderer produces a simple full-tunnel config with no suffix-based bypasses.

With the default engine.type=sing-box, the generated sing-box config contains the VLESS outbound directly. With engine.type=xray, render writes an Xray VLESS sidecar config and a sing-box TUN frontend config whose proxy outbound points at the local Xray SOCKS inbound. The frontend adds a direct process_name rule for Xray so the sidecar's upstream connection is not captured by the TUN.

vpn-core inspect-vless-url

Inspects a VLESS subscription URL or literal vless:// URI and prints safe profile metadata: payload format, profile count, display name, endpoint, protocol, security, network, SNI, fingerprint, and whether sensitive fields are present. It intentionally does not print the raw URI, UUID, public key, or short ID.

For HTTP subscriptions the probe runs an analyzer chain and prints every attempt. The chain covers the default request, explicit Host/SNI options, insecure TLS, browser/v2ray user agents, and certificate-derived Host/SNI hints. That keeps one-off provider URLs debuggable without editing scripts.

vpn-core inspect-vless-url "vless://..."
vpn-core inspect-vless-url --insecure "https://example.com/subscription"
vpn-core inspect-vless-url --host subrouter.fyi --tls-server-name subrouter.fyi "https://203.0.113.10:7654/freedom/token"

Local Config

Default live config path:

  • ~/.config/vless-tun/config.json

Repo-local example:

  • configs/local.example.json

If you want to keep using a repo-local config, pass --config.

Example config:

{
  "current": {
    "server": "dance",
    "profile": "default"
  },
  "servers": {
    "dance": {
      "source": {
        "mode": "proxy",
        "url": "https://key.vpn.dance/connect?key=REPLACE_ME"
      },
      "cache_dir": "~/.cache/vless-tun/dance",
      "artifacts": {
        "singbox_config_path": "~/.config/vless-tun/generated/sing-box_dance.json"
      },
      "engine": {
        "type": "sing-box"
      },
      "routing": {
        "bypass_suffixes": [
          ".ru",
          ".рф"
        ],
        "bypass_exclude_suffixes": [
          ".telegram.org",
          "t.me"
        ],
        "routes": []
      },
      "profiles": {
        "default": {
          "transport": "grpc"
        }
      }
    },
    "fortinetz": {
      "source": {
        "mode": "proxy",
        "url": "https://sub.fortinetz.com/sub/t/REPLACE_ME"
      },
      "cache_dir": "~/.cache/vless-tun/fortinetz",
      "artifacts": {
        "singbox_config_path": "~/.config/vless-tun/generated/sing-box_fortinetz.json"
      },
      "engine": {
        "type": "sing-box"
      },
      "routing": {
        "bypass_suffixes": [
          ".ru",
          ".рф"
        ],
        "bypass_exclude_suffixes": [
          ".telegram.org",
          "t.me"
        ],
        "routes": []
      },
      "profiles": {
        "nl": {
          "selector": "Netherlands"
        },
        "de": {
          "selector": "Germany"
        }
      }
    },
    "freedom": {
      "source": {
        "mode": "proxy",
        "url": "https://example.com/freedom/REPLACE_ME"
      },
      "cache_dir": "~/.cache/vless-tun/freedom",
      "artifacts": {
        "singbox_config_path": "~/.config/vless-tun/generated/sing-box_freedom.json",
        "xray_config_path": "~/.config/vless-tun/generated/xray_freedom.json"
      },
      "engine": {
        "type": "xray",
        "xray": {
          "executable": "xray",
          "socks_listen": "127.0.0.1",
          "socks_port": 20808,
          "process_names": ["xray"]
        }
      },
      "routing": {
        "bypass_suffixes": [
          ".ru",
          ".рф"
        ],
        "bypass_exclude_suffixes": [
          ".telegram.org",
          "t.me"
        ],
        "routes": []
      },
      "profiles": {
        "default": {
          "selector": ""
        }
      }
    }
  },
  "network": {
    "mode": "tun",
    "tun": {
      "interface_name": "utun233",
      "addresses": [
        "172.19.0.1/30",
        "fdfe:dcba:9876::1/126"
      ]
    }
  },
  "dns": {
    "proxy_resolver": {
      "address": "1.1.1.1",
      "port": 853,
      "tls_server_name": "cloudflare-dns.com"
    }
  },
  "logging": {
    "level": "warn",
    "max_lines": 1000
  }
}

Field reference:

  • current.server: selected configured server/source, such as dance or fortinetz
  • current.profile: selected local profile alias under the selected server
  • servers.<name>.source.mode: proxy or direct
  • servers.<name>.source.url: the actual source address; in proxy mode this is an HTTP endpoint that resolves to one or more vless:// entries, and in direct mode this is a literal vless://... URI
  • servers.<name>.cache_dir: local runtime/cache directory for that server's refresh snapshots, session logs, and runtime metadata
  • servers.<name>.artifacts.singbox_config_path: generated sing-box config path for that server
  • servers.<name>.artifacts.xray_config_path: generated Xray sidecar config path for engine.type=xray; if omitted, the path is derived from the sing-box config name
  • servers.<name>.profiles.<profile>.transport: optional explicit VLESS transport selector, currently tcp or grpc. Set this when a provider returns one TCP and one gRPC profile for the same logical server, such as Dance
  • servers.<name>.profiles.<profile>.selector: optional selector by exact id, exact name, endpoint, transport name, or substring. If a source resolves to multiple remote profiles and transport is unset, startup fails instead of silently following provider order
  • servers.<name>.engine.type: required runtime engine for each configured server in multi-server configs; valid values are sing-box and xray
  • engine may also exist globally or per profile for shared settings and overrides, but every servers.<name> block still needs its own explicit engine.type
  • engine.xray.executable: Xray binary name or absolute path. The binary must be installed and runnable before start or reconnect
  • engine.xray.socks_listen: local Xray SOCKS inbound address, default 127.0.0.1
  • engine.xray.socks_port: local Xray SOCKS inbound port, default 20808
  • engine.xray.process_names: process names routed direct by the generated sing-box TUN frontend, defaulting to the Xray executable basename
  • network.mode: currently tun
  • network.tun.interface_name: TUN interface name for tun mode
  • network.tun.addresses: TUN addresses for tun mode
  • routing may exist globally, per server, or per profile; more specific values override broader values
  • routing.bypass_suffixes: domains that should go direct; set [] for full-tunnel bring-up
  • routing.bypass_exclude_suffixes: optional suffixes that must stay on proxy even when a broader bypass list exists
  • routing.routes: CIDRs/IPs that should route direct
  • singbox may exist globally, per server, or per profile; more specific values override broader values
  • singbox.sniff.enabled: controls the generated sing-box route sniff action. Default is true, preserving the previous renderer behavior
  • singbox.sniff.sniffers: optional sing-box sniffers list such as ["tls", "http"]
  • singbox.sniff.timeout: optional sniff timeout string such as "1s"
  • singbox.tls.fragment: optional boolean for sing-box outbound TLS fragmentation
  • singbox.tls.record_fragment: optional boolean for sing-box outbound TLS record fragmentation
  • singbox.tls.fragment_fallback_delay: optional delay string for fragmentation fallback, such as "500ms"
  • singbox.tls.curve_preferences: optional TLS curve preference list such as ["X25519MLKEM768", "X25519"]
  • dns.proxy_resolver: upstream DNS endpoint for proxied traffic
  • logging.level: runtime log level written into generated configs; valid values are trace, debug, info, warn, error, fatal, and panic. New configs default to warn so normal full-TUN sessions do not write every connection to disk. Xray sidecar rendering maps warn to Xray's warning
  • logging.max_lines: maximum number of tail lines retained in bounded session logs, default 1000. Set 0 to disable bounding. The shared vpn-core helper applies this to long-running sing-box stdout/stderr; vless-tun internal runtime events also use the same bounded append path
  • launch.mode: optional override for the runtime backend. Omit launch in the happy path and vless-tun will resolve to the shared vpn-core backend automatically when it is available
  • launch.label and launch.plist_path: legacy compatibility overrides only; the shared daemon now belongs to vpn-core, not to each sing-box session
  • legacy flat configs with root-level source, cache_dir, artifacts, and default.profile_selector still load; new multi-server configs are preferred for more than one provider/source

Example per-server sing-box tuning:

{
  "servers": {
    "freedom": {
      "singbox": {
        "sniff": {
          "enabled": true,
          "sniffers": ["tls", "http"],
          "timeout": "1s"
        },
        "tls": {
          "fragment": true,
          "record_fragment": true,
          "fragment_fallback_delay": "500ms",
          "curve_preferences": ["X25519MLKEM768", "X25519"]
        }
      }
    }
  }
}

Example per-server Xray engine override:

{
  "servers": {
    "freedom": {
      "artifacts": {
        "singbox_config_path": "~/.config/vless-tun/generated/sing-box_freedom.json",
        "xray_config_path": "~/.config/vless-tun/generated/xray_freedom.json"
      },
      "engine": {
        "type": "xray",
        "xray": {
          "executable": "xray",
          "socks_listen": "127.0.0.1",
          "socks_port": 20808,
          "process_names": ["xray"]
        }
      }
    }
  }
}

VLESS URI parameters such as encryption=mlkem... and spx=... are parsed into the profile cache. With engine.type=xray, they are written into the Xray sidecar config. singbox.tls.curve_preferences is a sing-box TLS setting; it is not a replacement for unsupported VLESS outbound encryption fields in sing-box versions that reject them.

Full TUN on macOS

Example config fragment for a real TUN session managed by the shared vpn-core daemon without any explicit launch override:

{
  "network": {
    "mode": "tun",
    "tun": {
      "interface_name": "utun233",
      "addresses": [
        "172.19.0.1/30",
        "fdfe:dcba:9876::1/126"
      ]
    }
  }
}

Then run:

vpn-core install
vless-tun reconnect --refresh
vless-tun status

Development

go fmt ./...
go test ./...
go build -o vless-tun ./desktop/cmd/vless-tun
go build -o openconnect-tun ./desktop/cmd/openconnect-tun
go build -o dump ./desktop/cmd/dump
go build -o cisco-dump ./desktop/cmd/cisco-dump

Notes

  • This version manages the local sing-box session lifecycle with start, status, stop, and a configurable privileged TUN backend for macOS.
  • start refreshes the selected provider subscription before rendering by default, so configured profile aliases follow provider-side endpoint changes. When a provider exposes multiple remote profiles, configure transport or selector; vless-tun no longer silently follows provider order. --refresh=false is the explicit cached fallback for offline starts.
  • reconnect is the "restart with latest config" path: it rereads local config, stops any recorded VLESS session across configured server cache directories, refreshes the subscription by default, rerenders, and starts the selected session.
  • status is an introspection view over recorded session state, launch backend, process liveness, interface presence, and cached profile data; it is not a deep traffic verifier.
  • If your public IP does not change, check the latest session log first. The expected control flow is start -> status -> inspect the session log, not status alone.
  • system_proxy render mode has been removed; legacy configs should use network.mode=tun and drop any old network.system_proxy block.
  • Design note: Why vless-tun stays TUN-only
  • Generated config now includes route.default_domain_resolver, which sing-box 1.13.x expects as part of the DNS resolver migration path.
  • Every start gets its own timestamped log file so later debugging has a stable artifact even if the next session behaves differently.
  • The bypass rule is intentionally domain-suffix based because the original user requirement was *.ru. If later you want IP or community rulesets, extend the renderer rather than hardcoding provider-specific blobs.

About Relux Works

This project is part of the open-source ecosystem of Relux Works, an AI-native software development studio. We build fixed-price MVPs, rescue vibe-coded apps, run local AI inference, and train teams to work with coding agents. Much of the infrastructure behind that work is open source.

About

Multi-VPN orchestrator for macOS with VLESS and OpenConnect coexistence

Resources

Stars

5 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors