Skip to content
relux-worksPublic

About

Multi-VPN orchestrator for macOS with VLESS and OpenConnect coexistence

Resources

Stars

5 stars

Watchers

0 watching

Forks

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
  • ssh-proxy for a local SSH dynamic SOCKS5 proxy with explicit lifecycle state
  • 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 203.0.113.77 · Finland (FI).

Quick Start

./scripts/setup.sh
./scripts/setup.sh --mac-arch amd64
./scripts/setup.sh --install-vpn-config-skill
./scripts/deinit.sh --dry-run
./scripts/mac-deploy/deploy-multi-tun.sh --config-bundle /path/to/config

# edit ~/.config/vless-tun/config.json and set servers.<name>.source.url
# edit ~/.config/ssh-proxy/config.json or run ssh-proxy setup --host <ssh-alias> --force

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
ssh-proxy setup --host dedicated.example --server dedicated --force
ssh-proxy start
ssh-proxy status
ssh-proxy stop
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 and the zbarimg QR reader, builds the bundled desktop/cmd/vpn-auth Swift helper, installs the source-controlled vpn-config skill, 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.

After its Homebrew dependency steps, host-native setup verifies that the active openconnect is the Homebrew-owned executable and can run --version. This catches a bottle left linked to a removed nettle/libhogweed ABI; only that failed launch triggers brew reinstall openconnect, followed by another launch check. Non-Homebrew openconnect installations and cross-build mode are untouched. To run just this safe linkage check and repair seam:

./scripts/setup.sh --check-homebrew-openconnect

./scripts/deinit.sh removes managed ~/.local/bin symlinks. Config, cache, Keychain secrets, the installed vpn-config skill, and repo build artifacts stay intact unless an explicit purge flow covers them.

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/
scripts/install-vpn-config-skill.sh Install the tracked vpn-config skill source for Claude and Codex ./scripts/setup.sh --install-vpn-config-skill ~/.agents/skills/vpn-config plus links under ~/.claude/skills/ and ~/.codex/skills/
agents/skills/vpn-config/scripts/import-google-auth-qr-to-keychain.sh Decode a local Google Authenticator export QR without disclosing its payload or TOTP secret and store the secret through Security.framework agents/skills/vpn-config/scripts/import-google-auth-qr-to-keychain.sh --image /absolute/path/export.png --keychain-account example-vpn/totp-secret macOS Keychain service multi-tun; no secret-bearing file output
zbarimg Read a Google Authenticator export QR locally for the safe Keychain importer installed by ./scripts/setup.sh, or brew install zbar QR payload stays in importer process memory
scripts/tests/setup-openconnect-linkage-test.sh Fake-PATH regression coverage for Homebrew OpenConnect linkage, full skill installation, and cross-build isolation bash scripts/tests/setup-openconnect-linkage-test.sh Ephemeral fixtures under ${TMPDIR:-/tmp}
scripts/tests/vpn-config-skill-test.sh Validate generic config assets and the non-disclosing Google Authenticator QR-to-Keychain pipeline bash scripts/tests/vpn-config-skill-test.sh Ephemeral synthetic fixtures under ${TMPDIR:-/tmp}
PlantUML / Graphviz Validate and render architecture diagrams as code plantuml --check-syntax diagrams/plantuml/sequence/*.puml, plantuml --format svg --output-dir "$PWD/diagrams/artefacts/plantuml" diagrams/plantuml/sequence/*.puml diagrams/artefacts/plantuml/
scripts/mac-deploy/deploy-multi-tun.sh Fresh install or git-based update of the macOS toolchain on another Mac, with optional local-only config bundle installation scripts/mac-deploy/deploy-multi-tun.sh --config-bundle /path/to/config target checkout under ~/src/multi-tun by default; copied configs under ~/.config/; backups beside replaced config files
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
ssh-proxy Local SSH dynamic SOCKS5 proxy lifecycle management ssh-proxy setup --host ssh-alias --force, ssh-proxy start, ssh-proxy status, ssh-proxy stop ~/.config/ssh-proxy/, ~/.cache/ssh-proxy/, per-session SSH logs
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 typed-action backend for tunnel startup, DNS changes, resolver cleanup, and packet capture; also includes read-only user-space interface auditing and a VLESS subscription metadata probe DIGEST="$(vpn-core install-digest)" && vpn-core install --expect-sha256 "$DIGEST", vpn-core status, vpn-core interfaces --json, vpn-core inspect-vless-url --insecure <url> Root-owned helper and pinned tools under /Library/PrivilegedHelperTools/works.relux.vpn-core/, root-only runtime inputs under /private/var/run/works.relux.vpn-core/, redacted request-health state, client-owned logs, and interface audit output on stdout
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

ssh-proxy setup --host dedicated.example --server dedicated --force
ssh-proxy start
ssh-proxy status
ssh-proxy stop

ssh-proxy

ssh-proxy manages local SSH dynamic forwarding as an independent runtime. It uses the system SSH client with BatchMode=yes, ExitOnForwardFailure=yes, keepalives, and a readiness probe before it records the session. It never stores keys, passwords, or SSH-agent material; authentication remains in the normal OpenSSH configuration and Keychain flow.

Bootstrap a concrete profile in one command:

ssh-proxy setup \
  --server dedicated \
  --host relux-works-dedicated-macmini \
  --listen-address 127.0.0.1 \
  --socks-port 1080 \
  --force
ssh-proxy start
ssh-proxy status
ssh-proxy stop

The resulting ~/.config/ssh-proxy/config.json keeps the selected profile under current and its host, optional account, local bind IP, SOCKS port, and runtime cache path under servers.<name>. Leave account empty to use the User configured for an OpenSSH host alias. Session metadata and logs live under ~/.cache/ssh-proxy/<profile>/; status reads this state without touching VLESS or OpenConnect sessions.

For a SOCKS tunnel whose SSH transport must avoid an active full VLESS TUN, add the remote SSH endpoint IP to routing.routes and its hostname to routing.bypass_suffixes in vless-tun before starting ssh-proxy. This keeps the SSH transport direct while traffic sent through the local SOCKS listener egresses from the remote SSH host.

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
DIGEST="$(vpn-core install-digest)"
vpn-core install --expect-sha256 "$DIGEST"
openconnect-tun status
openconnect-tun helper status
openconnect-tun helper status
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-digest snapshots the symlink-resolved sing-box found on PATH into bin/sing-box, records its SHA-256 in manifest.json, and returns a domain-separated, length-framed digest covering the helper plus that manifest: sha256("works.relux.vpn-core.pkg/v1" || 0x00 || u64be(len(vpn-core)) || vpn-core || u64be(len(manifest.json)) || manifest.json), the same framing the mac-infra privileged helper verifies. vpn-core install --expect-sha256 HEX installs the snapshot root:wheel 0755 and verifies every manifest entry before launchd sees it. Both commands report the pinned version/source/hash and remind operators to rerun the digest/install flow after a Homebrew upgrade. The manifest/tool model has reserved PR C work for pinned openconnect and vpnc-script.
  • At startup, the daemon verifies its executable, manifest, every listed file including bin/ entries, root ownership, non-writable modes, no-follow path components, and macOS ACLs before opening the socket. The final socket directory is a narrow exception: it must remain root-owned and not world-writable, while group write is allowed only for wheel (gid 0) or daemon (gid 1); helper paths and all ancestors retain the strict non-writable rule. The daemon binds and permissions the socket in the strictly verified private sibling directory <HelperDir>.run (for example /Library/PrivilegedHelperTools/works.relux.vpn-core.run, so the helper tree itself keeps exactly the packaged entries), then atomically publishes it at the unchanged public path with rename(2).
  • Non-cgo builds fail closed. RPC peers are checked with LOCAL_PEERCRED; clients also require server uid 0 before sending any request bytes or SCM_RIGHTS descriptors. Requests are size/time/concurrency bounded, and singbox.start logs use a client-opened regular fd passed with SCM_RIGHTS.
  • Generic run and spawn no longer exist. The accepted mutating actions are singbox.start, dns.scutil, dns.networksetup, resolver.cleanup, tcpdump.start, and opaque-handle signal. singbox.start accepts validated JSON bytes and writes its root-owned 0600 copy only under /private/var/run/works.relux.vpn-core/; DNS actions validate a fixed grammar/service/IP model; resolver cleanup is native Go with no helper shell; tcpdump output is created inside the daemon runtime and handed to the authenticated client after stop. Unknown fields and actions are refused.
  • vpn-core status compares each installed pinned tool with the current symlink-resolved PATH binary. It prints drift: sing-box pinned=<short> path=<short> only when the bytes differ (or path=missing when absent). The check is unprivileged and read-only.
  • PR B transition for OpenConnect is intentionally narrow: OpenConnect launch and stop use the existing sudo/direct lanes even when helper was requested, while orphan resolver cleanup uses typed resolver.cleanup whenever vpn-core is reachable. PR C must add pinned openconnect plus vpnc-script, a daemon-rendered root-owned script/data model, openconnect.start, opaque-handle session metadata/signals, and then restore a true helper launch lane.
  • User-visible effect (owner decision 2026-10-18, option c): after the hardened install, openconnect-tun start and stop ask for the Mac password (sudo, cached about 5 minutes), so root runs OpenConnect, vpnc-script and vpn-slice only with explicit consent. A Go script hook that removes the prompt (PR C) is planned after a supervised corporate test.
  • vpn-core status and its openconnect-tun helper status compatibility wrapper extend the existing ping status response with active_requests, queued_requests, and last_completed_request. Entries contain only an allow-listed action plus age/duration and ok/error outcome metadata. Commands, stdin/cookies, log paths, tunnel endpoints, credentials, target PIDs, and signal values are never copied into the snapshot.
  • If a typed RPC misses the existing five-second response deadline, clients preserve the timeout and append a snapshot obtained through a short read-only ping on the same resolved helper socket. The probe does not start, stop, reconnect, or restart a tunnel or service.
  • Every OpenConnect launch now uses a collision-resistant attempt ID and matching --pid-file under ~/.cache/openconnect-tun/runtime. If helper startup times out after OpenConnect has backgrounded, the client recovers that exact PID, performs bounded PID-scoped INT/TERM/KILL cleanup, and removes only that attempt's PID file after exit. It never uses pkill or process-name-wide cleanup. If exit cannot be confirmed, the exact PID and PID-file path remain in current-session metadata so the session is still recoverable instead of becoming untracked.
  • Non-dry-run starts hold a cross-process, cache-scoped startup lock from session preflight through the final current-session handoff. A concurrent start for the same cache is refused with another openconnect startup is already in progress; starts using independent cache directories remain independent.
  • openconnect-tun start performs a read-only process scan before authentication and repeats it immediately before launch. A running OpenConnect command whose final server host matches the selected server but has no live current-session record causes start to fail with the exact PID; unrelated OpenConnect processes are not signalled or blocked. Inspect the reported process with ps -p <pid> -o pid=,ppid=,user=,etime=,command= and stop only that PID before retrying; do not use pkill openconnect.
  • 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 remain visible to compatibility status checks. A fresh vpn-core install --expect-sha256 HEX removes that legacy helper while installing the hardened shared core service.
  • openconnect-tun helper status|uninstall remain compatibility wrappers around the shared vpn-core service. The legacy helper install/daemon entrypoints fail closed and direct users to the digest-checked vpn-core install flow.
  • --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.
  • Until PR C adds typed openconnect.start, live OpenConnect launch uses the existing sudo -v + sudo -n path (or direct mode as root); an explicit helper selection is a compatibility alias for that safe transitional lane. The shared helper is still used for typed orphan resolver cleanup.
  • --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, save the QR as a local image and pass its path to the safe skill importer. It reads the payload and derived base32 secret only through process memory and stdin, then stores the secret directly in the Keychain account referenced by the config:

agents/skills/vpn-config/scripts/import-google-auth-qr-to-keychain.sh \
  --image '/absolute/path/to/export.png' \
  --keychain-account 'example-vpn/totp-secret'

Do not paste the QR payload or decoded secret into chat, command arguments, logs, or temporary files. See agents/skills/vpn-config/references/safe-totp-import.md for the agent procedure and multi-account selectors.

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>-<128-bit attempt suffix>.log
  • cache_dir runtime metadata: ~/.cache/openconnect-tun/runtime/current-session.json
  • cache_dir launch PID files: ~/.cache/openconnect-tun/runtime/openconnect-<UTC timestamp>-<128-bit attempt suffix>.pid; the active path is recorded as pid_file_path in session metadata and failed attempts remove it after confirmed PID-scoped cleanup
  • cache_dir helper/runtime files: files under ~/.cache/openconnect-tun/runtime/, including the persistent startup.lock inode and 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 is the one-command, synchronous stop-to-start path: it first refreshes and renders the selected provider/profile, then uses the same stop-all scan, waits for it to finish, and starts the replacement. If the refresh drops the selected profile or rendering fails, reconnect exits non-zero and leaves the running session untouched. Switching current.server therefore 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
vless-tun set-current phantombot/us

server/alias and server alias are equivalent; only the local configured names are persisted. 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. If the fetch fails transiently (network error, HTTP 5xx or 429), start and reconnect continue from the last cached snapshot and print a warning; a refused (4xx) or unparsable subscription stays a hard error. --refresh=false forces the cached snapshot without trying the network (it still fetches once when no cache exists).

Stable aliases may match refreshed provider entries by country/name/transport instead of pinning a rotating provider name or content-hash id. For example, this alias follows the current US endpoint and probes all matching candidates in parallel with one two-second TCP budget:

{
  "servers": {
    "phantombot": {
      "profiles": {
        "us": {
          "match": {"country": "US"},
          "pick": "probe"
        }
      }
    }
  }
}

Country detection prefers a flag emoji and otherwise recognizes whole Russian or English country names. A match may combine country, RE2 name_pattern, case-insensitive exclude substrings, and transport (tcp or grpc). pick is only by default, first follows subscription order, and probe selects the fastest successful endpoint with subscription order breaking equal-duration ties. While any configured VLESS session is running, probing is skipped: the persisted resolution for the same server/alias stays sticky when it is still a match, otherwise the first match is used. match and selector are mutually exclusive.

If the configured profile alias no longer matches any profile in the refreshed subscription, start fails before rendering or starting the tunnel and prints the available profiles. Successful match resolution is printed as resolved: <server>/<alias> -> <provider display name> (<id>) via <reason>, saved in session metadata/logs, and shown by status.

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, refreshes the subscription cache by default, rerenders the selected profile, and only then synchronously stops any currently recorded session across all configured VLESS server cache directories and starts a fresh vless-tun session with the selected engine. A profile-selection or render failure aborts before anything is stopped. A transient subscription fetch failure falls back to the cached snapshot with a warning; a refused or unparsable subscription, or a transient failure without a cache, is a hard failure, again before anything is stopped. Missing engine executables are also caught before the stop. Failures inside session startup after the stop (launch backend, nested-route guard, sidecar ports) can still leave no tunnel; rerun reconnect or start. It is the canonical single-command stop-to-start workflow; do not script separate stop and start calls for normal reconfiguration.

This is the command to use after changing:

  • current.server
  • current.profile
  • servers.<name>.profiles.<profile>.selector
  • servers.<name>.profiles.<profile>.match or .pick
  • 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.

By default, status reads only persisted session metadata and the cached subscription snapshot; it does not fetch or probe. Use --refresh to fetch explicitly. Candidate probing then runs only when no configured VLESS session is active.

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, daemon PID, redacted active/queued request metadata, and the last-completed request summary.

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

Resolves the selected profile from the cached subscription by default and writes runtime JSON config with the following content. Use --refresh to fetch explicitly before rendering.

  • 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.

For subscription compatibility, the sing-box renderer maps Xray's client-only flow=xtls-rprx-vision-udp443 spelling to sing-box's equivalent flow=xtls-rprx-vision. Xray rendering preserves the original flow value.

vpn-core interfaces

Audits the current macOS utun interfaces entirely in read-only user space. It reads interface and IPv4 route tables, then attributes an interface only when its exact name appears in the current vless-tun or openconnect-tun session record. Addresses and address ranges never imply ownership. Use --json for structured output.

vpn-core interfaces
vpn-core interfaces --json

The classes are:

  • owned: an exact session claim is alive; the report shows its engine and session ID and gives no advice.
  • unverified: a session claim is dead or its liveness cannot be determined; the only guidance is to rerun that engine's status command. A dead record cannot prove interface continuity because macOS may have reused the name.
  • unknown: an IPv4-bearing interface has no claim from either repo engine and is labeled not ours (another app or system) with no advice.
  • system: an unclaimed utun has no IPv4 address, as with the compact IPv6-link-local macOS system entries; it receives no advice.

Claim precedence is alive > unknown > dead. A default route is shown only as a fact in every class. The audit is purely informational: nothing in its output can mutate a tunnel, process, route, or interface.

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>.source.user_agent: optional User-Agent for proxy subscription fetches (default vless-tun/0.1); some providers return a stub profile unless the request looks like a known client
  • servers.<name>.source.headers: optional map of extra HTTP headers for proxy subscription fetches (for example device/HWID headers a provider requires); values may identify the device, so keep them in the local config only
  • non-vless:// entries in a mixed subscription (for example hysteria2://) are skipped; a malformed vless:// entry still fails the refresh
  • 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>.profiles.<profile>.match: stable provider-entry criteria. Every field is optional but at least one is required: country is a case-insensitive ISO 3166-1 alpha-2 code derived from a flag or whole country name, name_pattern is RE2, exclude contains case-insensitive rejected substrings, and transport is tcp or grpc. It cannot coexist with selector
  • servers.<name>.profiles.<profile>.pick: match disambiguation policy: only (default, exactly one), first (subscription order), or probe (fastest successful parallel TCP connect within a two-second total budget)
  • 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; ordinary settings such as routes use the more-specific override rule
  • routing.bypass_suffixes: domains that should go direct. Non-empty values are additive across global, server, and profile scopes, so a machine-wide bypass cannot be suppressed by a selected server/profile. Set an explicit [] at a more-specific scope for a deliberate full-tunnel reset.
  • 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
  • routing.route_descriptions: optional { "CIDR-or-IP": "human-readable purpose" } annotations for direct routes; these labels do not alter routing behavior
  • 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:

DIGEST="$(vpn-core install-digest)"
vpn-core install --expect-sha256 "$DIGEST"
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, refreshes the subscription by default, rerenders, and only then stops any recorded VLESS session across configured server cache directories and starts the selected session. Preparation errors leave the running session in place.
  • start and reconnect fall back to the cached subscription snapshot, with a warning, when the subscription fetch fails transiently (network error, HTTP 5xx or 429); a 4xx refusal or unparsable payload is never masked by the cache. Fetch errors never echo the subscription URL.
  • connect and disconnect are compatibility aliases for start and stop, matching openconnect-tun.
  • 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

Releases

Packages

Contributors

Languages